Merge pull request #10549 from SivanCola/codex/fix-session-route-boundary

fix(desktop): isolate session-id routes from legacy paths / 隔离 session-id 路由与旧版文件路径
This commit is contained in:
SivanCola authored and GitHub committed 2026-09-20 02:16:30 +08:00
commit eeb6439111
24 files changed
+1579 -383

No files matched your search

+2 -2
View File
@@ -21,7 +21,7 @@ func TestSaveTabSessionMetaFoldsDeliveryFloorToStandard(t *testing.T) {
t.Fatal(err)
}
if err := saveTabSessionMetaSnapshot(tabSessionMetaSnapshot{path: path, qualityFloor: control.QualityFloorDelivery}); err != nil {
if err := saveTabSessionMetaSnapshot(tabSessionMetaSnapshot{path: legacySessionPath(path), qualityFloor: control.QualityFloorDelivery}); err != nil {
t.Fatal(err)
}
got, ok, err := agent.LoadBranchMeta(path)
@@ -44,7 +44,7 @@ func TestSaveTabSessionMetaStandardWritesCompatibilityValue(t *testing.T) {
if _, err := agent.EnsureBranchMeta(path); err != nil {
t.Fatal(err)
}
if err := saveTabSessionMetaSnapshot(tabSessionMetaSnapshot{path: path, qualityFloor: control.QualityFloorStandard}); err != nil {
if err := saveTabSessionMetaSnapshot(tabSessionMetaSnapshot{path: legacySessionPath(path), qualityFloor: control.QualityFloorStandard}); err != nil {
t.Fatal(err)
}
got, ok, err := agent.LoadBranchMeta(path)
+13 -3
View File
@@ -785,7 +785,10 @@ func (a *App) restoreOrBuildTabs() {
// goal-state onto the fresh path; reading it here stops a restart
// from re-seeding the cleared goal into the rotated session. A
// session without a sidecar keeps the persisted goal (legacy).
tab.goal = runningTabSessionGoal(strings.TrimSpace(entry.SessionPath), strings.TrimSpace(entry.Goal))
tab.goal = strings.TrimSpace(entry.Goal)
if !entry.restoreBlocked {
tab.goal = runningTabSessionGoal(strings.TrimSpace(entry.SessionPath), tab.goal)
}
tab.toolApprovalMode = normalizeToolApprovalMode(entry.ToolApprovalMode)
if tab.toolApprovalMode == control.ToolApprovalAsk && tabModeHasAutoApproveTools(entry.Mode) {
tab.toolApprovalMode = control.ToolApprovalYolo
@@ -795,11 +798,18 @@ func (a *App) restoreOrBuildTabs() {
tab.PendingCreateOperationID = strings.TrimSpace(entry.CreateOperationID)
tab.persistenceExtra = cloneDesktopJSONFields(entry.extra)
tab.ReadOnly = entry.ReadOnly
restoreTabPinnedContext(tab, entry.PinnedFiles)
if entry.restoreBlocked {
tab.StartupErr = "Saved session identity could not be verified. Recovery data was preserved."
tab.retainLegacyPinnedFiles(entry.PinnedFiles)
} else {
restoreTabPinnedContext(tab, entry.PinnedFiles)
}
tab.Takeover.Spectator = entry.TakeoverSpectator
tab.sink = &tabEventSink{tabID: tab.ID, app: a, ctx: ctx}
a.publishRestoredTab(tab, releaseAdmission)
toBuild = append(toBuild, tab)
if !entry.restoreBlocked {
toBuild = append(toBuild, tab)
}
}
a.mu.Lock()
if _, ok := a.tabs[f.ActiveTab]; ok {
+16 -1
View File
@@ -192,7 +192,22 @@ func prepareStartupPinnedContext(tab *WorkspaceTab, startupPath, persistedPath s
}
func restoreTabPinnedContext(tab *WorkspaceTab, legacy []string) {
state, err := loadOrMigratePinnedContextState(tab.SessionPath, legacy)
// Canonical identities and rejected locators must never enter the legacy
// sidecar migration. Keep upgrade input until a verified binding owns it.
if tab.SessionID != "" {
tab.retainLegacyPinnedFiles(legacy)
return
}
path := ""
if tab.SessionPath != "" {
validated, ok := validatedLegacySessionPathForRead(tab.SessionPath)
if !ok {
tab.retainLegacyPinnedFiles(legacy)
return
}
path = string(validated)
}
state, err := loadOrMigratePinnedContextState(path, legacy)
if err != nil {
tab.retainLegacyPinnedFiles(legacy)
slog.Warn("desktop: restore pinned context", "err", err)
+75 -19
View File
@@ -29,9 +29,11 @@ const (
type savedTabReconcileDecision struct {
outcome savedTabReconcileOutcome
reason string
identityKind string
waitedForMigration bool
hadPending bool
hadRecoveryOwner bool
repairedIdentity bool
}
type savedTabReconcileEvidence struct {
@@ -58,7 +60,7 @@ func (a *App) reconcileTabsBeforeRestore(ctx context.Context, file desktopTabsFi
}
if err != nil {
slog.Warn("desktop_saved_tab_reconcile_persist_failed", "reason", "write_failed")
return original, version, a.tabsSnapshotCurrent(version)
return blockSavedTabUnsafeRestore(original, "identity_repair_write_failed"), version, a.tabsSnapshotCurrent(version)
}
}
return reconciled, version, a.tabsSnapshotCurrent(version)
@@ -80,6 +82,12 @@ func (a *App) reconcileSavedTabs(ctx context.Context, file desktopTabsFile) (des
anyNeedsMigration := false
repairedIdentity := false
for index := range file.Tabs {
if candidate := savedTabRouteCandidateForPath(file.Tabs[index].SessionPath); candidate.kind != "" {
decisions[index].identityKind = candidate.kind
needsMigration[index] = true
anyNeedsMigration = true
continue
}
if sessionID, found, conflict := savedTabPendingSessionIdentity(file.Tabs[index], fast); found && !conflict {
file.Tabs[index].SessionID = sessionID
repairedIdentity = true
@@ -98,46 +106,78 @@ func (a *App) reconcileSavedTabs(ctx context.Context, file desktopTabsFile) (des
if !needsMigration[index] {
continue
}
identityKind := decisions[index].identityKind
if !migrationFinished {
decisions[index] = savedTabReconcileDecision{outcome: preserveError, reason: "migration_interrupted", waitedForMigration: true}
decisions[index] = savedTabReconcileDecision{outcome: preserveError, reason: "migration_interrupted", identityKind: identityKind, waitedForMigration: true}
continue
}
if identityKind != "" {
normalized, repaired, override := a.normalizeSavedTabRoute(ctx, file.Tabs[index], afterMigration)
if override != nil {
override.identityKind = identityKind
override.waitedForMigration = true
decisions[index] = *override
continue
}
file.Tabs[index] = normalized
if repaired {
repairedIdentity = true
}
}
if sessionID, found, conflict := savedTabPendingSessionIdentity(file.Tabs[index], afterMigration); found && !conflict {
file.Tabs[index].SessionID = sessionID
repairedIdentity = true
}
decision, _ := a.classifySavedTab(file.Tabs[index], afterMigration, true)
if identityKind != "" {
decision.identityKind = identityKind
}
decision.repairedIdentity = strings.TrimSpace(file.Tabs[index].SessionID) != "" && strings.TrimSpace(file.Tabs[index].SessionPath) == "" && identityKind != ""
decision.waitedForMigration = true
decisions[index] = decision
}
}
filtered := make([]desktopTabEntry, 0, len(file.Tabs))
var removed map[string]bool
file.Tabs, removed = filterReconciledSavedTabs(file.Tabs, decisions)
if len(removed) == 0 && !repairedIdentity {
return file, false
}
repairReconciledTabSelection(&file, removed)
return file, true
}
func filterReconciledSavedTabs(tabs []desktopTabEntry, decisions []savedTabReconcileDecision) ([]desktopTabEntry, map[string]bool) {
filtered := make([]desktopTabEntry, 0, len(tabs))
removed := map[string]bool{}
for index, entry := range file.Tabs {
for index, entry := range tabs {
decision := decisions[index]
if decision.outcome == dropStalePresentation || decision.outcome == archiveEmptyThenDrop {
removed[entry.ID] = true
} else {
if decision.identityKind != "" && (decision.outcome == preserveError || decision.outcome == preserveRecovery) {
entry.restoreBlocked = true
entry.restoreBlockReason = decision.reason
}
filtered = append(filtered, entry)
}
if decision.outcome != restoreTab || decision.waitedForMigration {
slog.Info("desktop_saved_tab_reconciled",
"outcome", decision.outcome,
"reason", decision.reason,
"identity_kind", savedTabIdentityKind(entry),
"waited_for_migration", decision.waitedForMigration,
"had_pending_operation", decision.hadPending,
"had_recovery_owner", decision.hadRecoveryOwner,
)
if decision.outcome == restoreTab && !decision.waitedForMigration && !decision.repairedIdentity {
continue
}
identityKind := decision.identityKind
if identityKind == "" {
identityKind = savedTabIdentityKind(entry)
}
slog.Info("desktop_saved_tab_reconciled",
"outcome", decision.outcome,
"reason", decision.reason,
"identity_kind", identityKind,
"waited_for_migration", decision.waitedForMigration,
"had_pending_operation", decision.hadPending,
"had_recovery_owner", decision.hadRecoveryOwner,
)
}
if len(removed) == 0 && !repairedIdentity {
return file, false
}
file.Tabs = filtered
repairReconciledTabSelection(&file, removed)
return file, true
return filtered, removed
}
func (a *App) waitForDesktopMigration(ctx context.Context) bool {
@@ -182,6 +222,9 @@ func (a *App) classifySavedTab(entry desktopTabEntry, evidence savedTabReconcile
if !afterMigration {
return savedTabReconcileDecision{}, false
}
if _, ok, err := legacySessionPathForFileAccess(entry.SessionPath); err != nil || !ok {
return savedTabReconcileDecision{outcome: preserveError, reason: "invalid_legacy_path", identityKind: "invalid_legacy"}, true
}
return a.classifyLegacySavedTab(entry, evidence), true
}
if strings.TrimSpace(entry.CreateOperationID) != "" && !afterMigration {
@@ -407,6 +450,19 @@ func savedTabPendingSessionIdentity(entry desktopTabEntry, evidence savedTabReco
return "", false, true
}
}
for _, operation := range evidence.registry.PendingOperations {
if strings.TrimSpace(operation.ID) != operationID {
continue
}
for _, sessionID := range operation.SessionIDs {
if !accept(sessionID) {
return "", false, true
}
}
if operation.Mapping != nil && !accept(operation.Mapping.SessionID) {
return "", false, true
}
}
return resolved, resolved != "", false
}
+235
View File
@@ -0,0 +1,235 @@
package main
import (
"context"
"errors"
"os"
"path/filepath"
"runtime"
"slices"
"strings"
"reasonix/internal/session"
"reasonix/internal/store"
)
type savedTabRouteCandidate struct {
sessionID string
kind string
}
func savedTabRouteCandidateForPath(raw string) savedTabRouteCandidate {
value := strings.TrimSpace(raw)
if value == "" {
return savedTabRouteCandidate{}
}
if strings.HasPrefix(value, remoteSessionIDRoutePrefix) {
locator := classifySessionLocator(value)
if locator.kind != sessionLocatorCanonical {
return savedTabRouteCandidate{kind: "invalid_route"}
}
return savedTabRouteCandidate{sessionID: locator.ref.SessionID, kind: "canonical_route"}
}
if !persistedPathLooksAbsolute(value) {
return savedTabRouteCandidate{}
}
normalized := strings.ReplaceAll(value, `\`, "/")
base := normalized[strings.LastIndex(normalized, "/")+1:]
if !strings.HasPrefix(base, remoteSessionIDRoutePrefix) {
return savedTabRouteCandidate{}
}
// A real legacy transcript or sidecar can legally contain a colon on
// POSIX. Those names remain paths and are never upgraded by this repair.
if strings.HasSuffix(strings.ToLower(base), ".jsonl") || strings.HasSuffix(strings.ToLower(base), ".meta") {
return savedTabRouteCandidate{}
}
locator := classifySessionLocator(base)
if locator.kind != sessionLocatorCanonical {
return savedTabRouteCandidate{kind: "invalid_route"}
}
return savedTabRouteCandidate{sessionID: locator.ref.SessionID, kind: "pseudo_route_path"}
}
func persistedPathLooksAbsolute(path string) bool {
if strings.HasPrefix(path, "/") || strings.HasPrefix(path, `\\`) || strings.HasPrefix(path, "//") {
return true
}
return len(path) >= 3 && ((path[0] >= 'A' && path[0] <= 'Z') || (path[0] >= 'a' && path[0] <= 'z')) && path[1] == ':' && (path[2] == '\\' || path[2] == '/')
}
func (a *App) normalizeSavedTabRoute(ctx context.Context, entry desktopTabEntry, evidence savedTabReconcileEvidence) (desktopTabEntry, bool, *savedTabReconcileDecision) {
candidate := savedTabRouteCandidateForPath(entry.SessionPath)
failed := func(reason string, pending, recovery bool) (desktopTabEntry, bool, *savedTabReconcileDecision) {
return entry, false, &savedTabReconcileDecision{
outcome: preserveError, reason: reason, identityKind: candidate.kind,
hadPending: pending, hadRecoveryOwner: recovery,
}
}
if candidate.kind == "" {
return entry, false, nil
}
if candidate.kind == "invalid_route" || candidate.sessionID == "" {
return failed("invalid_route", false, false)
}
if id := strings.TrimSpace(entry.SessionID); id != "" && id != candidate.sessionID {
return failed("identity_conflict", false, false)
}
if evidence.registryErr != nil || evidence.draftErr != nil {
return failed("persistence_state_unavailable", false, false)
}
pendingID, pendingFound, pendingConflict := savedTabPendingSessionIdentity(desktopTabEntry{
CreateOperationID: entry.CreateOperationID,
}, evidence)
if pendingConflict || (pendingFound && pendingID != candidate.sessionID) {
return failed("pending_identity_conflict", true, false)
}
if candidate.kind == "canonical_route" {
entry.SessionID = candidate.sessionID
entry.SessionPath = ""
return entry, true, nil
}
present, artifactErr := savedTabPseudoRouteArtifactsPresent(entry.SessionPath)
if artifactErr != nil {
return failed("legacy_artifacts_unreadable", pendingFound, false)
}
if present {
return failed("legacy_artifacts_present", pendingFound, false)
}
confirmed, conflict, recoveryOwner := a.savedTabPseudoRouteIdentityConfirmed(ctx, entry, candidate.sessionID, evidence, pendingID, pendingFound)
if conflict {
return failed("identity_conflict", pendingFound, recoveryOwner)
}
if !confirmed {
return failed("identity_evidence_absent", pendingFound, recoveryOwner)
}
entry.SessionID = candidate.sessionID
entry.SessionPath = ""
return entry, true, nil
}
func savedTabPseudoRouteArtifactsPresent(path string) (bool, error) {
return savedTabPseudoRouteArtifactsPresentWith(path, os.Lstat)
}
func savedTabPseudoRouteArtifactsPresentWith(path string, lstat func(string) (os.FileInfo, error)) (bool, error) {
path = strings.TrimSpace(path)
if path == "" {
return false, nil
}
if runtime.GOOS == "windows" && savedTabRouteCandidateForPath(path).kind == "pseudo_route_path" {
// The route colon is illegal in a Windows file name, so this spelling
// cannot identify a real transcript or sidecar on the native filesystem.
return false, nil
}
// Foreign Windows paths on POSIX cannot name a local artifact. Native
// paths are probed without cleaning so the persisted spelling is preserved.
if runtime.GOOS != "windows" && !filepath.IsAbs(path) {
return false, nil
}
artifacts := append([]string{path}, store.SessionSidecarFiles(path)...)
artifacts = append(artifacts,
store.SessionLockFile(path), store.SessionLeaseLock(path), store.SessionLeaseInfo(path),
store.SessionCheckpointDir(path), store.SessionJobsDir(path), store.SessionInboxDir(path),
store.SessionCleanupPending(path),
)
seen := make(map[string]bool, len(artifacts))
for _, artifact := range artifacts {
if artifact == "" || seen[artifact] {
continue
}
seen[artifact] = true
if _, err := lstat(artifact); err == nil {
return true, nil
} else if !errors.Is(err, os.ErrNotExist) {
return false, err
}
}
return false, nil
}
func (a *App) savedTabPseudoRouteIdentityConfirmed(ctx context.Context, entry desktopTabEntry, sessionID string, evidence savedTabReconcileEvidence, pendingID string, pendingFound bool) (bool, bool, bool) {
confirmed := pendingFound && pendingID == sessionID
owners := savedTabDurableRouteOwnerIDs(entry, sessionID, evidence)
if len(owners) > 1 || (len(owners) == 1 && !owners[sessionID]) {
return false, true, len(owners) > 0
}
if owners[sessionID] {
confirmed = true
}
info, err := a.desktopSessionService("").Query().Stat(ctx, session.SessionRef{HostID: localDesktopHostID, SessionID: sessionID})
if err == nil {
workspace, consistent := savedTabCanonicalWorkspace(evidence.registry, info, sessionID)
if consistent && savedTabMatchesWorkspace(entry, workspace) {
confirmed = true
}
}
return confirmed, false, len(owners) > 0
}
func savedTabDurableRouteOwnerIDs(entry desktopTabEntry, candidateID string, evidence savedTabReconcileEvidence) map[string]bool {
owners := map[string]bool{}
operationID := strings.TrimSpace(entry.CreateOperationID)
rawPath := strings.TrimSpace(entry.SessionPath)
workspaceID := restoredWorkspaceID(entry)
for sessionID, pending := range evidence.registry.PendingCreates {
if (operationID != "" && pending.OperationID == operationID) || (sessionID == candidateID && pending.WorkspaceID == workspaceID) {
owners[strings.TrimSpace(sessionID)] = true
}
}
for _, operation := range evidence.draftOps {
if operationID != "" && operation.ID == operationID {
owners[strings.TrimSpace(operation.SessionID)] = true
}
}
for _, operation := range evidence.registry.PendingOperations {
if operationID != "" && strings.TrimSpace(operation.ID) == operationID {
for _, id := range operation.SessionIDs {
owners[strings.TrimSpace(id)] = true
}
}
if operation.Mapping != nil && strings.TrimSpace(operation.Mapping.Path) == rawPath {
owners[strings.TrimSpace(operation.Mapping.SessionID)] = true
}
if operation.WorkspaceID == workspaceID && slices.Contains(operation.SessionIDs, candidateID) {
owners[candidateID] = true
}
}
for _, mapping := range evidence.registry.SourceMappings {
if strings.TrimSpace(mapping.Path) == rawPath {
owners[strings.TrimSpace(mapping.SessionID)] = true
}
if mapping.WorkspaceID == workspaceID && mapping.SessionID == candidateID {
owners[candidateID] = true
}
}
for _, recovery := range evidence.registry.RecoveryEntries {
if strings.TrimSpace(recovery.Path) == rawPath {
owners[strings.TrimSpace(recovery.SessionID)] = true
}
if recovery.SessionID == candidateID && (recovery.WorkspaceID == workspaceID || recovery.Scope == entry.Scope && sameDesktopPath(recovery.WorkspaceRoot, entry.WorkspaceRoot)) {
owners[candidateID] = true
}
}
delete(owners, "")
return owners
}
func blockSavedTabUnsafeRestore(file desktopTabsFile, reason string) desktopTabsFile {
file.Tabs = append([]desktopTabEntry(nil), file.Tabs...)
for index := range file.Tabs {
path := strings.TrimSpace(file.Tabs[index].SessionPath)
unsafe := savedTabRouteCandidateForPath(path).kind != ""
if !unsafe && path != "" {
_, ok, err := legacySessionPathForFileAccess(path)
unsafe = err != nil || !ok
}
if unsafe {
file.Tabs[index].restoreBlocked = true
file.Tabs[index].restoreBlockReason = reason
}
}
return file
}
+254
View File
@@ -0,0 +1,254 @@
package main
import (
"encoding/json"
"errors"
"os"
"path/filepath"
"runtime"
"testing"
"reasonix/desktop/internal/workspacestate"
)
func TestReconcileSavedTabsNormalizesExactCanonicalRoute(t *testing.T) {
app := newSavedTabReconcileTestApp(t)
root := t.TempDir()
ref, workspaceID := createLegacyCleanupSession(t, app, root, "exact-route", true)
finishSavedTabMigration(app)
entry := savedProjectTab("saved", "", "", root, workspaceID)
entry.SessionPath = sessionRoute(ref.SessionID)
entry.extra = map[string]json.RawMessage{"future": json.RawMessage(`{"kept":true}`)}
got, changed := app.reconcileSavedTabs(t.Context(), desktopTabsFile{Tabs: []desktopTabEntry{entry}, ActiveTab: entry.ID})
if !changed || len(got.Tabs) != 1 {
t.Fatalf("exact route reconciliation = changed:%v file:%+v", changed, got)
}
if got.Tabs[0].SessionID != ref.SessionID || got.Tabs[0].SessionPath != "" {
t.Fatalf("exact route identity = id:%q path:%q", got.Tabs[0].SessionID, got.Tabs[0].SessionPath)
}
if string(got.Tabs[0].extra["future"]) != `{"kept":true}` {
t.Fatalf("unknown entry field lost: %s", mustMarshalJSON(t, got.Tabs[0]))
}
second, changedAgain := app.reconcileSavedTabs(t.Context(), got)
if changedAgain || second.Tabs[0].SessionID != ref.SessionID || second.Tabs[0].SessionPath != "" {
t.Fatalf("route repair was not idempotent: changed=%v file=%+v", changedAgain, second)
}
}
func TestReconcileSavedTabsNormalizesWindowsPseudoRouteWithCanonicalEvidence(t *testing.T) {
app := newSavedTabReconcileTestApp(t)
root := t.TempDir()
ref, workspaceID := createLegacyCleanupSession(t, app, root, "windows-pseudo-route", true)
finishSavedTabMigration(app)
entry := savedProjectTab("saved", "", "", root, workspaceID)
entry.SessionPath = `C:\old\reasonix\session-id:` + ref.SessionID
got, changed := app.reconcileSavedTabs(t.Context(), desktopTabsFile{Tabs: []desktopTabEntry{entry}})
if !changed || len(got.Tabs) != 1 || got.Tabs[0].SessionID != ref.SessionID || got.Tabs[0].SessionPath != "" {
t.Fatalf("pseudo route reconciliation = changed:%v file:%+v", changed, got)
}
}
func TestReconcileSavedTabsBlocksConflictingAndInvalidRoutes(t *testing.T) {
for _, test := range []struct {
name string
entry desktopTabEntry
}{
{name: "conflicting ids", entry: desktopTabEntry{ID: "conflict", Scope: "global", SessionID: "other", SessionPath: "session-id:route-id"}},
{name: "invalid route", entry: desktopTabEntry{ID: "invalid", Scope: "global", SessionPath: "session-id:bad/id"}},
{name: "sidecar is not transcript", entry: desktopTabEntry{ID: "sidecar", Scope: "global", SessionPath: filepath.Join(t.TempDir(), "session-id:route.meta")}},
{name: "relative legacy path", entry: desktopTabEntry{ID: "relative", Scope: "global", SessionPath: "relative.jsonl"}},
} {
t.Run(test.name, func(t *testing.T) {
app := newSavedTabReconcileTestApp(t)
finishSavedTabMigration(app)
got, changed := app.reconcileSavedTabs(t.Context(), desktopTabsFile{Tabs: []desktopTabEntry{test.entry}})
if changed || len(got.Tabs) != 1 || !got.Tabs[0].restoreBlocked {
t.Fatalf("blocked route = changed:%v file:%+v", changed, got)
}
if got.Tabs[0].SessionID != test.entry.SessionID || got.Tabs[0].SessionPath != test.entry.SessionPath {
t.Fatalf("conflicting identity was modified: got=%+v want=%+v", got.Tabs[0], test.entry)
}
})
}
}
func TestReconcileSavedTabsPreservesRealPseudoRouteArtifact(t *testing.T) {
if runtime.GOOS == "windows" {
t.Skip("POSIX permits colons in file names")
}
app := newSavedTabReconcileTestApp(t)
root := t.TempDir()
ref, workspaceID := createLegacyCleanupSession(t, app, root, "real-artifact-route", true)
finishSavedTabMigration(app)
path := filepath.Join(t.TempDir(), sessionRoute(ref.SessionID))
if err := os.WriteFile(path, []byte("legacy content"), 0o600); err != nil {
t.Fatal(err)
}
entry := savedProjectTab("saved", "", "", root, workspaceID)
entry.SessionPath = path
got, changed := app.reconcileSavedTabs(t.Context(), desktopTabsFile{Tabs: []desktopTabEntry{entry}})
if changed || len(got.Tabs) != 1 || !got.Tabs[0].restoreBlocked || got.Tabs[0].SessionPath != path {
t.Fatalf("real pseudo-route artifact was rewritten: changed=%v file=%+v", changed, got)
}
}
func TestPseudoRouteArtifactProbePreservesUnreadableResult(t *testing.T) {
want := errors.New("permission denied")
path := filepath.Join(t.TempDir(), "session-id:probe")
if runtime.GOOS == "windows" {
// A pseudo-route file name is unrepresentable on Windows and is skipped
// before Lstat. Use a valid transcript spelling to exercise error flow.
path = filepath.Join(t.TempDir(), "probe.jsonl")
}
present, err := savedTabPseudoRouteArtifactsPresentWith(path, func(string) (os.FileInfo, error) {
return nil, want
})
if present || !errors.Is(err, want) {
t.Fatalf("artifact probe = present:%v err:%v", present, err)
}
}
func TestRestoreBlocksInvalidRouteWithoutControllerOrSidecar(t *testing.T) {
app := newSavedTabReconcileTestApp(t)
finishSavedTabMigration(app)
entry := desktopTabEntry{ID: "invalid", Scope: "global", TopicID: "topic", SessionPath: "session-id:bad/id"}
file := desktopTabsFile{Tabs: []desktopTabEntry{entry}, ActiveTab: entry.ID, TabOrder: []string{entry.ID}}
if err := os.MkdirAll(desktopConfigDir(), 0o700); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(desktopConfigDir(), tabsFileName), mustMarshalJSON(t, file), 0o600); err != nil {
t.Fatal(err)
}
app.tabsRestored = make(chan struct{})
app.restoreOrBuildTabs()
tab := app.tabs[entry.ID]
if tab == nil || tab.Ctrl != nil || tab.StartupErr == "" {
t.Fatalf("blocked restore tab = %+v", tab)
}
if len(app.runtimeByID) != 0 || len(app.runtimeBySessionKey) != 0 {
t.Fatalf("blocked restore created runtime state: %d/%d", len(app.runtimeByID), len(app.runtimeBySessionKey))
}
for _, artifact := range []string{"session-id:bad/id.meta", "session-id:bad/id.lock", "session-id:bad/id.lease.lock"} {
_, err := os.Lstat(artifact)
if runtime.GOOS == "windows" && err != nil {
// The route colon makes the spelling unrepresentable on Windows.
continue
}
if !errors.Is(err, os.ErrNotExist) {
t.Fatalf("blocked route created %q: %v", artifact, err)
}
}
}
func TestReconcileTabsBeforeRestoreBlocksOriginalWhenRepairWriteFails(t *testing.T) {
app := newSavedTabReconcileTestApp(t)
root := t.TempDir()
ref, workspaceID := createLegacyCleanupSession(t, app, root, "write-failure-route", true)
finishSavedTabMigration(app)
entry := savedProjectTab("saved", "", "", root, workspaceID)
entry.SessionPath = sessionRoute(ref.SessionID)
file := desktopTabsFile{Tabs: []desktopTabEntry{entry}, ActiveTab: entry.ID}
destination := filepath.Join(desktopConfigDir(), tabsFileName)
if err := os.RemoveAll(destination); err != nil {
t.Fatal(err)
}
if err := os.MkdirAll(destination, 0o700); err != nil {
t.Fatal(err)
}
got, _, current := app.reconcileTabsBeforeRestore(t.Context(), file, app.tabsSnapshotVersion())
if !current || len(got.Tabs) != 1 || !got.Tabs[0].restoreBlocked {
t.Fatalf("failed repair write restore = current:%v file:%+v", current, got)
}
if got.Tabs[0].SessionID != "" || got.Tabs[0].SessionPath != entry.SessionPath {
t.Fatalf("failed repair write published unpersisted identity: %+v", got.Tabs[0])
}
}
func TestReconcileTabsBeforeRestoreRejectsRouteRepairAfterConcurrentSave(t *testing.T) {
app := newSavedTabReconcileTestApp(t)
root := t.TempDir()
ref, workspaceID := createLegacyCleanupSession(t, app, root, "concurrent-route", true)
entry := savedProjectTab("saved", "", "", root, workspaceID)
entry.SessionPath = sessionRoute(ref.SessionID)
file := desktopTabsFile{Tabs: []desktopTabEntry{entry}, ActiveTab: entry.ID}
reached := make(chan struct{})
app.beforeSavedTabMigrationWait = func() { close(reached) }
type result struct {
file desktopTabsFile
current bool
}
done := make(chan result, 1)
version := app.tabsSnapshotVersion()
go func() {
got, _, current := app.reconcileTabsBeforeRestore(t.Context(), file, version)
done <- result{file: got, current: current}
}()
<-reached
app.mu.Lock()
app.tabsSaveVersion++
app.mu.Unlock()
finishSavedTabMigration(app)
got := <-done
if got.current {
t.Fatal("stale startup reconciliation remained publishable")
}
if got.file.Tabs[0].SessionID != "" || got.file.Tabs[0].SessionPath != entry.SessionPath {
t.Fatalf("stale repair escaped its snapshot: %+v", got.file.Tabs[0])
}
}
func TestReconcileSavedTabsPseudoRouteUsesPendingAndRecoveryEvidence(t *testing.T) {
t.Run("pending create", func(t *testing.T) {
app := newSavedTabReconcileTestApp(t)
root := t.TempDir()
workspaceID, err := app.ensureDesktopWorkspace(t.Context(), "project", root)
if err != nil {
t.Fatal(err)
}
const sessionID = "pending-pseudo-route"
const operationID = "pending-pseudo-operation"
if err := app.workspaceRegistry().BeginCreate(t.Context(), workspacestate.PendingCreate{
OperationID: operationID, WorkspaceID: workspaceID, SessionID: sessionID,
}); err != nil {
t.Fatal(err)
}
finishSavedTabMigration(app)
entry := savedProjectTab("pending", "", operationID, root, workspaceID)
entry.SessionPath = `C:\old\session-id:` + sessionID
got, changed := app.reconcileSavedTabs(t.Context(), desktopTabsFile{Tabs: []desktopTabEntry{entry}})
if !changed || len(got.Tabs) != 1 || got.Tabs[0].SessionID != sessionID || got.Tabs[0].SessionPath != "" || got.Tabs[0].restoreBlocked {
t.Fatalf("pending pseudo-route evidence = changed:%v file:%+v", changed, got)
}
})
t.Run("recovery owner", func(t *testing.T) {
app := newSavedTabReconcileTestApp(t)
root := t.TempDir()
workspaceID, err := app.ensureDesktopWorkspace(t.Context(), "project", root)
if err != nil {
t.Fatal(err)
}
const sessionID = "recovery-pseudo-route"
if err := app.workspaceRegistry().RecordRecovery(t.Context(), workspacestate.RecoveryEntry{
ID: "recovery-pseudo", SourceKey: "pseudo", SessionID: sessionID, WorkspaceID: workspaceID,
Scope: "project", WorkspaceRoot: root, Format: "canonical", Reason: "interrupted", Status: "pending",
}); err != nil {
t.Fatal(err)
}
finishSavedTabMigration(app)
entry := savedProjectTab("recovery", "", "", root, workspaceID)
entry.SessionPath = `C:\old\session-id:` + sessionID
got, changed := app.reconcileSavedTabs(t.Context(), desktopTabsFile{Tabs: []desktopTabEntry{entry}})
if !changed || len(got.Tabs) != 1 || got.Tabs[0].SessionID != sessionID || got.Tabs[0].SessionPath != "" {
t.Fatalf("recovery pseudo-route evidence = changed:%v file:%+v", changed, got)
}
if !got.Tabs[0].restoreBlocked {
t.Fatal("missing recovery content was allowed to create a replacement runtime")
}
})
}
+1 -1
View File
@@ -519,7 +519,7 @@ func (a *App) saveTabSessionMetaSnapshotAndIndex(snap tabSessionMetaSnapshot) er
}
// Transcript saves index through the observer; enqueue again after the
// sidecar commit so scope and title changes are visible without a full scan.
a.requestSessionCatalogIndexPath(snap.scope, snap.workspaceRoot, snap.path)
a.requestSessionCatalogIndexPath(snap.scope, snap.workspaceRoot, string(snap.path))
return nil
}
+11 -8
View File
@@ -56,16 +56,19 @@ func TestProjectNodeFromCatalogTopicFiltersIdleRecoveryCopies(t *testing.T) {
func TestProjectNodesFromCatalogTopicKeepsSharedTopicSessionsIndependent(t *testing.T) {
app := &App{tabs: map[string]*WorkspaceTab{}, detachedSessions: map[string]*WorkspaceTab{}}
dir := t.TempDir()
pathA := filepath.Join(dir, "a.jsonl")
pathB := filepath.Join(dir, "b.jsonl")
topic := sessioncatalog.TopicRecord{
Scope: "global", TopicID: "shared", Title: "Shared title", Turns: 5,
TurnsState: sessioncatalog.TurnsValid, Health: sessioncatalog.HealthOK,
Sessions: []sessioncatalog.SessionRecord{
{Path: "/s/a.jsonl", CustomTitle: "A", Turns: 5, TurnsState: sessioncatalog.TurnsValid, Health: sessioncatalog.HealthOK, LastActivityAt: 100},
{Path: "/s/b.jsonl", CustomTitle: "B", Turns: 2, TurnsState: sessioncatalog.TurnsValid, Health: sessioncatalog.HealthOK, LastActivityAt: 90},
{Path: pathA, CustomTitle: "A", Turns: 5, TurnsState: sessioncatalog.TurnsValid, Health: sessioncatalog.HealthOK, LastActivityAt: 100},
{Path: pathB, CustomTitle: "B", Turns: 2, TurnsState: sessioncatalog.TurnsValid, Health: sessioncatalog.HealthOK, LastActivityAt: 90},
},
}
overlays := map[string]catalogRuntimeOverlay{
sessionRuntimeKey("/s/a.jsonl"): {open: true, running: true, status: topicStatusThinking},
sessionRuntimeKey(pathA): {open: true, running: true, status: topicStatusThinking},
}
nodes := app.projectNodesFromCatalogTopic(topic, map[string]catalogRuntimeOverlay{}, overlays, nil)
if len(nodes) != 2 {
@@ -75,13 +78,13 @@ func TestProjectNodesFromCatalogTopicKeepsSharedTopicSessionsIndependent(t *test
for _, node := range nodes {
byPath[node.SessionPath] = node
}
if !byPath["/s/a.jsonl"].Running || byPath["/s/a.jsonl"].Label != "A" {
t.Fatalf("A = %+v, want its own runtime and title", byPath["/s/a.jsonl"])
if !byPath[pathA].Running || byPath[pathA].Label != "A" {
t.Fatalf("A = %+v, want its own runtime and title", byPath[pathA])
}
if byPath["/s/b.jsonl"].Running || byPath["/s/b.jsonl"].Label != "B" {
t.Fatalf("B = %+v, must not inherit A runtime", byPath["/s/b.jsonl"])
if byPath[pathB].Running || byPath[pathB].Label != "B" {
t.Fatalf("B = %+v, must not inherit A runtime", byPath[pathB])
}
if byPath["/s/a.jsonl"].Key == byPath["/s/b.jsonl"].Key {
if byPath[pathA].Key == byPath[pathB].Key {
t.Fatal("shared-topic sessions must have distinct row keys")
}
}
+5 -3
View File
@@ -19,9 +19,11 @@ func sessionRoute(id string) string {
}
func parseSessionRoute(route string) (string, bool) {
id, ok := strings.CutPrefix(strings.TrimSpace(route), remoteSessionIDRoutePrefix)
id = strings.TrimSpace(id)
return id, ok && id != ""
locator := classifySessionLocator(route)
if locator.kind != sessionLocatorCanonical {
return "", false
}
return locator.ref.SessionID, true
}
func (a *App) listCanonicalSessionsFromDir(dir, active string) []SessionMeta {
+151
View File
@@ -0,0 +1,151 @@
package main
import (
"errors"
"os"
"path/filepath"
"reflect"
"testing"
"reasonix/desktop/internal/workspacestate"
"reasonix/internal/store"
)
func TestRestoreBlockedIdentityPreservesPinsWithoutSidecars(t *testing.T) {
app := newSavedTabReconcileTestApp(t)
finishSavedTabMigration(app)
path := filepath.Join(t.TempDir(), "session-id:unverified")
entry := desktopTabEntry{ID: "blocked", Scope: "global", SessionPath: path, Goal: "saved goal", PinnedFiles: []string{"README.md"}}
file := desktopTabsFile{Tabs: []desktopTabEntry{entry}, ActiveTab: entry.ID, TabOrder: []string{entry.ID}}
if err := os.MkdirAll(desktopConfigDir(), 0o700); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(desktopConfigDir(), tabsFileName), mustMarshalJSON(t, file), 0o600); err != nil {
t.Fatal(err)
}
app.tabsRestored = make(chan struct{})
app.restoreOrBuildTabs()
tab := app.tabs[entry.ID]
if tab == nil || tab.StartupErr == "" || tab.Ctrl != nil {
t.Fatalf("blocked restore published an unsafe runtime: %+v", tab)
}
if got := persistedDesktopTabEntry(tab); got.SessionPath != path || got.Goal != entry.Goal || !reflect.DeepEqual(got.PinnedFiles, entry.PinnedFiles) {
t.Fatalf("blocked restore lost saved input: %+v", got)
}
artifacts, err := os.ReadDir(filepath.Dir(path))
if err != nil || len(artifacts) != 0 {
t.Fatalf("blocked restore created artifacts: %v, %v", artifacts, err)
}
}
func TestRestorePinnedContextRejectsUnverifiedLocators(t *testing.T) {
for _, path := range []string{"session-id:valid", "session-id:bad/id", filepath.Join(t.TempDir(), "session-id:unverified")} {
t.Run(path, func(t *testing.T) {
tab := &WorkspaceTab{SessionPath: path}
restoreTabPinnedContext(tab, []string{"legacy.md"})
if got := tab.pendingLegacyPinnedFilesForPersistence(); !reflect.DeepEqual(got, []string{"legacy.md"}) {
t.Fatalf("unverified input was consumed: %v", got)
}
if _, err := os.Stat(store.SessionPinnedContext(path)); err == nil {
t.Fatal("unverified locator created pinned sidecar")
}
})
}
}
func TestReconcileSavedTabsRejectsPendingOperationIdentityConflicts(t *testing.T) {
for _, pseudo := range []bool{false, true} {
for _, mapping := range []bool{false, true} {
name := "exact"
if pseudo {
name = "pseudo"
}
if mapping {
name += "-mapping"
}
t.Run(name, func(t *testing.T) {
app := newSavedTabReconcileTestApp(t)
root := t.TempDir()
ref, workspaceID := createLegacyCleanupSession(t, app, root, "session-A", true)
op := workspacestate.Operation{ID: "operation", Kind: "import", Lifecycle: workspacestate.Active, WorkspaceID: workspaceID, SessionIDs: []string{"session-B"}}
if mapping {
op.SessionIDs = []string{ref.SessionID}
op.Mapping = &workspacestate.SourceMapping{SessionID: "session-B"}
}
if err := app.workspaceRegistry().BeginOperation(t.Context(), op); err != nil {
t.Fatal(err)
}
before, err := app.workspaceRegistry().Load(t.Context())
if err != nil {
t.Fatal(err)
}
finishSavedTabMigration(app)
entry := savedProjectTab("conflict", "", op.ID, root, workspaceID)
entry.SessionPath = sessionRoute(ref.SessionID)
if pseudo {
entry.SessionPath = `C:\old\` + entry.SessionPath
}
got, changed := app.reconcileSavedTabs(t.Context(), desktopTabsFile{Tabs: []desktopTabEntry{entry}})
if changed || len(got.Tabs) != 1 || !got.Tabs[0].restoreBlocked {
t.Fatalf("pending conflict was accepted: changed=%v tabs=%+v", changed, got.Tabs)
}
if got.Tabs[0].SessionID != entry.SessionID || got.Tabs[0].SessionPath != entry.SessionPath || got.Tabs[0].CreateOperationID != op.ID {
t.Fatalf("pending conflict changed saved identity: %+v", got.Tabs[0])
}
after, err := app.workspaceRegistry().Load(t.Context())
if err != nil || !reflect.DeepEqual(before, after) {
t.Fatalf("conflict mutated registry: %v", err)
}
})
}
}
}
func TestReconcileSavedTabsAcceptsMatchingPendingOperationIdentity(t *testing.T) {
app := newSavedTabReconcileTestApp(t)
root := t.TempDir()
ref, workspaceID := createLegacyCleanupSession(t, app, root, "session-A", true)
op := workspacestate.Operation{ID: "operation", Kind: "import", Lifecycle: workspacestate.Active, WorkspaceID: workspaceID, SessionIDs: []string{ref.SessionID}}
if err := app.workspaceRegistry().BeginOperation(t.Context(), op); err != nil {
t.Fatal(err)
}
finishSavedTabMigration(app)
entry := savedProjectTab("matching", "", op.ID, root, workspaceID)
entry.SessionPath = sessionRoute(ref.SessionID)
got, changed := app.reconcileSavedTabs(t.Context(), desktopTabsFile{Tabs: []desktopTabEntry{entry}})
if !changed || len(got.Tabs) != 1 || got.Tabs[0].restoreBlocked || got.Tabs[0].SessionID != ref.SessionID || got.Tabs[0].SessionPath != "" {
t.Fatalf("matching pending identity was rejected: changed=%v tabs=%+v", changed, got.Tabs)
}
}
func TestHiddenTabPruneRejectsCanonicalIdentityConflict(t *testing.T) {
app := newSavedTabReconcileTestApp(t)
root := t.TempDir()
ref, workspaceID := createLegacyCleanupSession(t, app, root, "session-A", true)
before, err := app.workspaceRegistry().Load(t.Context())
if err != nil {
t.Fatal(err)
}
for _, path := range []string{"session-id:session-B", "session-id:bad/id", filepath.Join(t.TempDir(), "session-id:session-B"), filepath.Join(t.TempDir(), "legacy.jsonl")} {
t.Run(path, func(t *testing.T) {
tab := &WorkspaceTab{ID: "conflict", Scope: "project", WorkspaceRoot: root, SessionWorkspace: desktopTabWorkspace{ID: workspaceID}, SessionID: ref.SessionID, SessionPath: path}
app.tabs[tab.ID] = tab
for _, save := range []func() error{
func() error { return app.persistHiddenTabBeforePrune(tab.ID, tab) },
func() error { return app.saveTabSessionMeta(tab, sessionRoute(ref.SessionID)) },
} {
var identityErr *sessionLocatorError
if err := save(); !errors.As(err, &identityErr) {
t.Fatalf("conflicting identity accepted: %v", err)
}
}
if tab.SessionID != ref.SessionID || tab.SessionPath != path {
t.Fatal("failed persistence changed tab identity")
}
})
}
after, err := app.workspaceRegistry().Load(t.Context())
if err != nil || !reflect.DeepEqual(before, after) {
t.Fatalf("failed prune mutated registry: %v", err)
}
}
+7 -3
View File
@@ -16,17 +16,21 @@ func (t *WorkspaceTab) handoffSessionLease(path string) (*agent.SessionLease, er
if t == nil || t.ReadOnly {
return nil, nil
}
key := sessionRuntimeKey(path)
if key == "" {
legacyPath, ok, err := legacySessionPathForFileAccess(path)
if err != nil {
return nil, err
}
if !ok {
return nil, nil
}
key := sessionRuntimeKey(string(legacyPath))
t.sessionLeaseMu.Lock()
if t.sessionLease != nil && sessionRuntimeKey(t.sessionLease.Path()) == key {
t.storeSessionLeaseRuntimeKey(key)
t.sessionLeaseMu.Unlock()
return nil, nil
}
lease, err := agent.TryAcquireSessionLease(key)
lease, err := agent.TryAcquireSessionLease(string(legacyPath))
if err != nil {
t.sessionLeaseMu.Unlock()
return nil, err
+118
View File
@@ -0,0 +1,118 @@
package main
import (
"fmt"
"path/filepath"
"runtime"
"strings"
"reasonix/internal/session"
"reasonix/internal/store"
)
type sessionLocatorKind uint8
const (
sessionLocatorEmpty sessionLocatorKind = iota
sessionLocatorCanonical
sessionLocatorLegacy
sessionLocatorInvalid
)
type legacySessionPath string
type sessionLocator struct {
kind sessionLocatorKind
ref session.SessionRef
legacyPath legacySessionPath
reason string
}
func isLegacySessionTranscriptName(name string) bool {
if runtime.GOOS == "windows" {
name = strings.ToLower(name)
}
return store.IsSessionTranscriptName(name)
}
func validatedLegacySessionPathForRead(raw string) (legacySessionPath, bool) {
locator := classifySessionLocator(raw)
if locator.kind != sessionLocatorLegacy {
return "", false
}
path := string(locator.legacyPath)
if !filepath.IsAbs(path) {
return "", false
}
validated, err := resolveLegacySessionPath(path, filepath.Dir(path))
return validated, err == nil
}
func legacySessionPathForFileAccess(raw string) (legacySessionPath, bool, error) {
locator := classifySessionLocator(raw)
switch locator.kind {
case sessionLocatorEmpty, sessionLocatorCanonical:
return "", false, nil
case sessionLocatorInvalid:
return "", false, &sessionLocatorError{reason: locator.reason}
}
path := string(locator.legacyPath)
if !filepath.IsAbs(path) {
return "", false, &sessionLocatorError{reason: "legacy_path_not_absolute"}
}
validated, err := resolveLegacySessionPath(path, filepath.Dir(path))
if err != nil {
return "", false, err
}
return validated, true, nil
}
type sessionLocatorError struct {
reason string
}
func (e *sessionLocatorError) Error() string {
if e == nil || e.reason == "" {
return "invalid session identity"
}
return fmt.Sprintf("invalid session identity: %s", e.reason)
}
// classifySessionLocator is deliberately path-I/O free. A value with the
// canonical route prefix is always a route candidate: malformed IDs never
// fall through to the legacy path branch.
func classifySessionLocator(raw string) sessionLocator {
value := strings.TrimSpace(raw)
if value == "" {
return sessionLocator{kind: sessionLocatorEmpty}
}
if id, route := strings.CutPrefix(value, remoteSessionIDRoutePrefix); route {
id = strings.TrimSpace(id)
if err := session.ValidateSessionID(id); err != nil {
return sessionLocator{kind: sessionLocatorInvalid, reason: "invalid_canonical_route"}
}
return sessionLocator{
kind: sessionLocatorCanonical,
ref: session.SessionRef{HostID: localDesktopHostID, SessionID: id},
}
}
return sessionLocator{kind: sessionLocatorLegacy, legacyPath: legacySessionPath(value)}
}
func resolveLegacySessionPath(raw string, dirs ...string) (legacySessionPath, error) {
locator := classifySessionLocator(raw)
switch locator.kind {
case sessionLocatorEmpty:
return "", &sessionLocatorError{reason: "empty"}
case sessionLocatorCanonical:
return "", &sessionLocatorError{reason: "canonical_route_is_not_a_path"}
case sessionLocatorInvalid:
return "", &sessionLocatorError{reason: locator.reason}
}
for _, dir := range dirs {
if path, ok := pinnedTabSessionPath(dir, string(locator.legacyPath)); ok {
return legacySessionPath(path), nil
}
}
return "", &sessionLocatorError{reason: "invalid_legacy_path"}
}
+204
View File
@@ -0,0 +1,204 @@
package main
import (
"context"
"errors"
"os"
"path/filepath"
"strings"
"testing"
"reasonix/internal/store"
)
func TestSessionLocatorSeparatesCanonicalRoutesFromLegacyPaths(t *testing.T) {
legacy := filepath.Join(t.TempDir(), "session-id:real.jsonl")
tests := []struct {
name string
raw string
kind sessionLocatorKind
id string
}{
{name: "empty", raw: " ", kind: sessionLocatorEmpty},
{name: "canonical", raw: " session-id:valid-id ", kind: sessionLocatorCanonical, id: "valid-id"},
{name: "empty canonical id", raw: "session-id:", kind: sessionLocatorInvalid},
{name: "canonical separator", raw: "session-id:bad/id", kind: sessionLocatorInvalid},
{name: "canonical reserved name", raw: "session-id:CON", kind: sessionLocatorInvalid},
{name: "canonical control", raw: "session-id:bad\x01id", kind: sessionLocatorInvalid},
{name: "canonical windows punctuation", raw: `session-id:bad<id>`, kind: sessionLocatorInvalid},
{name: "canonical question mark", raw: `session-id:bad?id`, kind: sessionLocatorInvalid},
{name: "canonical too long", raw: "session-id:" + strings.Repeat("x", 256), kind: sessionLocatorInvalid},
{name: "legacy colon filename", raw: legacy, kind: sessionLocatorLegacy},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
got := classifySessionLocator(test.raw)
if got.kind != test.kind || got.ref.SessionID != test.id {
t.Fatalf("locator = kind:%v id:%q reason:%q", got.kind, got.ref.SessionID, got.reason)
}
})
}
}
func TestSavedTabRouteCandidateUsesOnlyExactRouteComponents(t *testing.T) {
tests := []struct {
name, raw, kind, id string
}{
{name: "exact", raw: "session-id:valid-id", kind: "canonical_route", id: "valid-id"},
{name: "windows drive", raw: `C:\old\session-id:valid-id`, kind: "pseudo_route_path", id: "valid-id"},
{name: "windows UNC", raw: `\\server\share\session-id:valid-id`, kind: "pseudo_route_path", id: "valid-id"},
{name: "posix", raw: "/old/session-id:valid-id", kind: "pseudo_route_path", id: "valid-id"},
{name: "relative", raw: "old/session-id:valid-id"},
{name: "intermediate component", raw: "/old/session-id:valid-id/history"},
{name: "transcript", raw: "/old/session-id:valid-id.jsonl"},
{name: "sidecar", raw: "/old/session-id:valid-id.jsonl.meta"},
{name: "invalid exact", raw: "session-id:bad/id", kind: "invalid_route"},
{name: "invalid pseudo", raw: "/old/session-id:bad?id", kind: "invalid_route"},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
got := savedTabRouteCandidateForPath(test.raw)
if got.kind != test.kind || got.sessionID != test.id {
t.Fatalf("candidate = kind:%q id:%q", got.kind, got.sessionID)
}
})
}
}
func TestTabSessionMetaRejectsRouteBeforeCreatingSidecars(t *testing.T) {
isolateDesktopUserDirs(t)
app := NewApp()
t.Cleanup(func() { _ = app.draftStore().Close() })
tab := &WorkspaceTab{ID: "route", Scope: "global", SessionPath: "session-id:valid-id"}
app.tabs[tab.ID] = tab
if err := app.saveTabSessionMetaForCurrentSession(tab); err != nil {
t.Fatalf("canonical compatibility route should skip legacy metadata: %v", err)
}
if _, err := os.Stat(store.SessionMeta(filepath.Join(desktopSessionDir(""), "session-id:valid-id"))); !errors.Is(err, os.ErrNotExist) {
t.Fatalf("canonical route produced a metadata sidecar: %v", err)
}
tab.SessionPath = "session-id:bad/id"
if err := app.saveTabSessionMetaForCurrentSession(tab); err == nil {
t.Fatal("invalid canonical route was silently accepted")
}
}
func TestCanonicalTabSessionMetaNeverWritesLegacySidecar(t *testing.T) {
isolateDesktopUserDirs(t)
app := NewApp()
t.Cleanup(func() { _ = app.draftStore().Close() })
tab := &WorkspaceTab{ID: "canonical", Scope: "global", SessionID: "canonical-id", SessionPath: "session-id:canonical-id"}
app.tabs[tab.ID] = tab
if err := app.saveTabSessionMetaForCurrentSession(tab); err != nil {
t.Fatal(err)
}
if err := app.saveTabSessionMeta(tab, "session-id:canonical-id"); err != nil {
t.Fatal(err)
}
if _, err := os.Stat("session-id:canonical-id.meta"); !errors.Is(err, os.ErrNotExist) {
t.Fatalf("canonical tab wrote legacy metadata: %v", err)
}
}
func TestSessionLocatorNeverCanonicalizesRouteAsPath(t *testing.T) {
if got := canonicalTabSessionPath("session-id:valid-id"); got != "" {
t.Fatalf("canonical route became path %q", got)
}
if got := canonicalTabSessionPath("session-id:bad/id"); got != "" {
t.Fatalf("invalid canonical route became path %q", got)
}
if got := sessionRuntimeKey("session-id:valid-id"); got != "session-id:valid-id" {
t.Fatalf("canonical runtime key = %q", got)
}
if got := sessionRuntimeKey("session-id:bad/id"); got != "" {
t.Fatalf("invalid route runtime key = %q", got)
}
if got := sessionRuntimeKey(filepath.Join(t.TempDir(), "not-a-transcript.meta")); got != "" {
t.Fatalf("invalid legacy runtime key = %q", got)
}
}
func TestResolveLegacySessionPathRejectsRoutesBeforePathResolution(t *testing.T) {
dir := t.TempDir()
if _, err := resolveLegacySessionPath("session-id:valid-id", dir); err == nil {
t.Fatal("canonical route was accepted as a legacy path")
}
if _, err := resolveLegacySessionPath("session-id:bad/id", dir); err == nil {
t.Fatal("invalid route was accepted as a legacy path")
}
legacy := filepath.Join(dir, "history.jsonl")
got, err := resolveLegacySessionPath(legacy, dir)
if err != nil || string(got) != legacy {
t.Fatalf("legacy path = %q, %v", got, err)
}
}
func TestLegacyReadersRejectCanonicalAndInvalidRoutes(t *testing.T) {
for _, route := range []string{"session-id:valid-id", "session-id:bad/id"} {
if _, ok := validatedLegacySessionPathForRead(route); ok {
t.Fatalf("route %q was accepted for legacy reads", route)
}
if profile := loadTabSessionProfile(route); profile != defaultTabSessionProfile() {
t.Fatalf("route %q loaded a legacy profile: %+v", route, profile)
}
if got := runningTabSessionGoal(route, "persisted goal"); got != "persisted goal" {
t.Fatalf("route %q read a legacy goal: %q", route, got)
}
if _, _, ok := topicTitleFallbackForOpen("", "topic", route); ok {
t.Fatalf("route %q loaded a legacy title", route)
}
}
}
func TestSessionLeaseRejectsRouteBeforeFileAccess(t *testing.T) {
tab := &WorkspaceTab{}
if err := tab.ensureSessionLease("session-id:valid-id"); err != nil {
t.Fatalf("canonical route should use Session Service ownership: %v", err)
}
if tab.sessionLease != nil {
t.Fatal("canonical route acquired a legacy file lease")
}
if err := tab.ensureSessionLease("session-id:bad/id"); err == nil {
t.Fatal("invalid route was silently accepted by the lease boundary")
}
for _, artifact := range []string{"session-id:valid-id.lease.lock", "session-id:valid-id.lease.json"} {
if _, err := os.Lstat(artifact); !errors.Is(err, os.ErrNotExist) {
t.Fatalf("canonical route created %q: %v", artifact, err)
}
}
}
func TestCanonicalHiddenTabPruneSkipsLegacyMetadataWriter(t *testing.T) {
app := newSavedTabReconcileTestApp(t)
root := t.TempDir()
ref, workspaceID := createLegacyCleanupSession(t, app, root, "hidden-canonical", true)
tab := &WorkspaceTab{
ID: "hidden", Scope: "project", WorkspaceRoot: root, SessionWorkspace: desktopTabWorkspace{ID: workspaceID},
SessionID: ref.SessionID, SessionPath: sessionRoute(ref.SessionID),
}
app.tabs[tab.ID] = tab
if err := app.persistHiddenTabBeforePrune(tab.ID, tab); err != nil {
t.Fatal(err)
}
for _, artifact := range []string{"session-id:hidden-canonical.meta", "session-id:hidden-canonical.lock"} {
if _, err := os.Lstat(artifact); !errors.Is(err, os.ErrNotExist) {
t.Fatalf("canonical hidden-tab prune created %q: %v", artifact, err)
}
}
}
func TestCanonicalDetachCloneAndReattachKeepExclusiveIdentity(t *testing.T) {
source := &WorkspaceTab{ID: "source", Scope: "global", SessionID: "canonical-transfer", SessionPath: sessionRoute("canonical-transfer")}
detached := cloneDetachedRuntimeTab(source, sessionRoute("canonical-transfer"), source.currentSessionIdentity())
if detached == nil || detached.SessionID != "canonical-transfer" || detached.SessionPath != "" {
t.Fatalf("detached identity = id:%q path:%q", detached.SessionID, detached.SessionPath)
}
target := &WorkspaceTab{ID: "target", Scope: "global"}
applyRuntimeTab(target, detached, sessionRoute("canonical-transfer"), context.Background(), nil)
if target.SessionID != "canonical-transfer" || target.SessionPath != "" {
t.Fatalf("reattached identity = id:%q path:%q", target.SessionID, target.SessionPath)
}
}
+5 -1
View File
@@ -21,7 +21,11 @@ import (
)
func desktopSourceKey(path, head string) string {
sum := sha256.Sum256([]byte(sessionRuntimeKey(path) + "\x00" + head))
// Migration sources include both transcript files and canonical prototype
// directories. Keep their persisted key independent from the runtime
// session locator, which intentionally accepts transcript paths only.
pathKey := agent.CanonicalSessionPath(cleanDesktopPath(path))
sum := sha256.Sum256([]byte(pathKey + "\x00" + head))
return hex.EncodeToString(sum[:])
}
+1 -1
View File
@@ -631,7 +631,7 @@ func resolveMigrationTarget(ctx context.Context, query *session.Query, preferred
if err := json.Unmarshal(body, &manifest); err != nil {
return false, err
}
if manifest.Source == nil || sessionRuntimeKey(manifest.Source.Path) != sessionRuntimeKey(sourcePaths[0]) {
if manifest.Source == nil || !sameDesktopPath(manifest.Source.Path, sourcePaths[0]) {
return false, nil
}
if len(sourcePaths) > 1 && sourcePaths[1] != "" && manifest.Source.LegacyHeadID != sourcePaths[1] {
+2 -2
View File
@@ -720,7 +720,7 @@ func validateSessionPath(dir, sessionPath string) (string, string, error) {
if err != nil {
return "", "", err
}
if !store.IsSessionTranscriptName(filepath.Base(absPath)) {
if !isLegacySessionTranscriptName(filepath.Base(absPath)) {
return "", "", fmt.Errorf("not a session file: %s", sessionPath)
}
rel, err := filepath.Rel(absDir, absPath)
@@ -765,7 +765,7 @@ func validateTrashedSessionPath(dir, sessionPath string) (string, string, string
if err != nil {
return "", "", "", err
}
if !store.IsSessionTranscriptName(filepath.Base(absPath)) {
if !isLegacySessionTranscriptName(filepath.Base(absPath)) {
return "", "", "", fmt.Errorf("not a session file: %s", sessionPath)
}
rel, err := filepath.Rel(root, absPath)
+258 -160
View File
@@ -384,10 +384,19 @@ func (t *WorkspaceTab) hasActiveRuntimeWork() bool {
// every rebuild on Windows look like a foreign holder (self-lock, #5999).
// Keys are identities only — never use them as display or file paths.
func sessionRuntimeKey(path string) string {
if id, ok := strings.CutPrefix(strings.TrimSpace(path), remoteSessionIDRoutePrefix); ok {
return remoteSessionIDRoutePrefix + strings.TrimSpace(id)
locator := classifySessionLocator(path)
switch locator.kind {
case sessionLocatorCanonical:
return sessionRoute(locator.ref.SessionID)
case sessionLocatorLegacy:
path, ok, err := legacySessionPathForFileAccess(string(locator.legacyPath))
if err != nil || !ok {
return ""
}
return agent.CanonicalSessionPath(string(path))
default:
return ""
}
return agent.CanonicalSessionPath(canonicalTabSessionPath(path))
}
var sessionLeaseAcquireHookForTest func()
@@ -396,17 +405,21 @@ func (t *WorkspaceTab) ensureSessionLease(path string) error {
if t == nil || t.ReadOnly {
return nil
}
key := sessionRuntimeKey(path)
if key == "" {
legacyPath, ok, err := legacySessionPathForFileAccess(path)
if err != nil {
return err
}
if !ok {
return nil
}
key := sessionRuntimeKey(string(legacyPath))
t.sessionLeaseMu.Lock()
if t.sessionLease != nil && sessionRuntimeKey(t.sessionLease.Path()) == key {
t.storeSessionLeaseRuntimeKey(key)
t.sessionLeaseMu.Unlock()
return nil
}
lease, err := agent.TryAcquireSessionLease(key)
lease, err := agent.TryAcquireSessionLease(string(legacyPath))
if err != nil {
t.sessionLeaseMu.Unlock()
return err
@@ -600,13 +613,18 @@ func setTabSessionIdentity(tab *WorkspaceTab, identity string) {
if tab == nil {
return
}
if id, ok := parseSessionRoute(identity); ok {
tab.SessionID = id
locator := classifySessionLocator(identity)
if locator.kind == sessionLocatorCanonical {
tab.SessionID = locator.ref.SessionID
tab.SessionPath = ""
return
}
tab.SessionID = ""
tab.SessionPath = canonicalTabSessionPath(identity)
if locator.kind == sessionLocatorLegacy {
tab.SessionPath = canonicalTabSessionPath(string(locator.legacyPath))
} else {
tab.SessionPath = ""
}
}
// cloneDetachedRuntimeTab copies a running tab's runtime state into a fresh
@@ -628,7 +646,7 @@ func cloneDetachedRuntimeTab(tab *WorkspaceTab, key, path string) *WorkspaceTab
tab.telemMu.Unlock()
pinnedFiles, pendingLegacyPinnedFiles := tab.pinnedFilesState()
return &WorkspaceTab{
detached := &WorkspaceTab{
ID: detachedRuntimeTabID(key),
Scope: tab.Scope,
WorkspaceRoot: tab.WorkspaceRoot,
@@ -637,8 +655,6 @@ func cloneDetachedRuntimeTab(tab *WorkspaceTab, key, path string) *WorkspaceTab
TopicID: tab.TopicID,
TopicTitle: tab.TopicTitle,
topicTitleSource: tab.topicTitleSource,
SessionID: tab.SessionID,
SessionPath: canonicalTabSessionPath(path),
Ctrl: tab.Ctrl,
Label: tab.Label,
Ready: tab.Ready,
@@ -664,6 +680,12 @@ func cloneDetachedRuntimeTab(tab *WorkspaceTab, key, path string) *WorkspaceTab
PinnedFiles: pinnedFiles,
pendingLegacyPinnedFiles: pendingLegacyPinnedFiles,
}
if tab.SessionID != "" {
setTabSessionIdentity(detached, sessionRoute(tab.SessionID))
} else {
setTabSessionIdentity(detached, path)
}
return detached
}
func (a *App) detachRuntimeForReplacement(tab *WorkspaceTab) bool {
@@ -760,8 +782,7 @@ func applyRuntimeTab(target, source *WorkspaceTab, path string, appCtx context.C
target.SessionID = source.SessionID
target.SessionPath = ""
} else {
target.SessionID = ""
target.SessionPath = canonicalTabSessionPath(path)
setTabSessionIdentity(target, path)
}
target.SharedHostKey = source.SharedHostKey
target.Label = source.Label
@@ -2170,15 +2191,18 @@ func (a *App) tabMeta(tab *WorkspaceTab, active bool) TabMeta {
if a.botBridge != nil {
m.RemoteControlled = a.botBridge.remoteControlledTabs()[tab.ID]
}
if meta, ok, err := agent.LoadBranchMeta(tab.currentSessionPath()); err == nil && ok {
m.VersionKind = string(meta.EffectiveVersionKind())
m.VersionState = string(meta.EffectiveVersionState())
m.ParentVersionID = meta.ParentVersionID
if meta.Recovered {
m.Recovered = true
m.RecoveryReason = meta.RecoveryReason
m.RecoveryDigest = meta.RecoveryDigest
m.RecoveryParentID = string(meta.ParentID)
legacyMetaPath, legacyMetaOK := validatedLegacySessionPathForRead(tab.currentSessionPath())
if legacyMetaOK {
if meta, ok, err := agent.LoadBranchMeta(string(legacyMetaPath)); err == nil && ok {
m.VersionKind = string(meta.EffectiveVersionKind())
m.VersionState = string(meta.EffectiveVersionState())
m.ParentVersionID = meta.ParentVersionID
if meta.Recovered {
m.Recovered = true
m.RecoveryReason = meta.RecoveryReason
m.RecoveryDigest = meta.RecoveryDigest
m.RecoveryParentID = string(meta.ParentID)
}
}
}
return m
@@ -4152,10 +4176,11 @@ func describeSessionBindingWorkspace(scope, workspaceRoot string) string {
}
func (a *App) resolveSessionBinding(sessionPath string) (sessionBinding, bool) {
sessionPath = strings.TrimSpace(sessionPath)
if sessionPath == "" {
legacyPath, ok := validatedLegacySessionPathForRead(sessionPath)
if !ok {
return sessionBinding{}, false
}
sessionPath = string(legacyPath)
for _, dir := range a.knownSessionDirs() {
if binding, ok := sessionBindingInDir(dir, sessionPath); ok {
return binding, true
@@ -4570,10 +4595,11 @@ func shouldApplyAutoTopicTitle(workspaceRoot, topicID string, proposal autoTopic
}
func sessionHasManualDisplayTitle(sessionPath string) bool {
sessionPath = strings.TrimSpace(sessionPath)
if sessionPath == "" {
legacyPath, ok := validatedLegacySessionPathForRead(sessionPath)
if !ok {
return false
}
sessionPath = string(legacyPath)
if meta, ok, err := agent.LoadBranchMeta(sessionPath); err == nil && ok {
if strings.TrimSpace(meta.CustomTitle) != "" {
return true
@@ -4588,10 +4614,11 @@ func sessionHasManualDisplayTitle(sessionPath string) bool {
func topicTitleFallbackForOpen(workspaceRoot, topicID, sessionPath string) (string, string, bool) {
topicID = strings.TrimSpace(topicID)
sessionPath = strings.TrimSpace(sessionPath)
if topicID == "" || sessionPath == "" {
legacyPath, ok := validatedLegacySessionPathForRead(sessionPath)
if topicID == "" || !ok {
return "", "", false
}
sessionPath = string(legacyPath)
storedTitle := strings.TrimSpace(loadTopicTitle(workspaceRoot, topicID))
storedSource := strings.TrimSpace(loadTopicTitleSource(workspaceRoot, topicID))
if storedTitle != "" {
@@ -4636,10 +4663,14 @@ func topicTitleUserTurnsFromSession(path string) []string {
}
func loadTopicTitleUserTurnsFromSession(path string) ([]string, error) {
legacyPath, ok := validatedLegacySessionPathForRead(path)
if !ok {
return nil, &sessionLocatorError{reason: "invalid_legacy_path"}
}
// Event-log aware: decoding the .jsonl checkpoint directly would stop
// seeing user turns after the first save, silently disabling the ≥3-turn
// title upgrade.
msgs, err := agent.LoadSessionUserMessages(path)
msgs, err := agent.LoadSessionUserMessages(string(legacyPath))
if err != nil {
return nil, err
}
@@ -7168,107 +7199,138 @@ func pinnedTabSessionPathForBuild(scope, workspaceRoot, targetDir, sessionPath s
// (controller reads happen off-lock) so a concurrent tab mutation can't tear
// the persisted record.
func (a *App) saveTabSessionMeta(tab *WorkspaceTab, path string) error {
if tab == nil || strings.TrimSpace(path) == "" {
if tab == nil {
return nil
}
a.mu.RLock()
ctrl := tab.Ctrl
snap := tabSessionMetaSnapshot{
path: path,
scope: tab.Scope,
workspaceRoot: tab.WorkspaceRoot,
topicID: tab.TopicID,
topicTitle: tab.TopicTitle,
tokenMode: currentTabTokenMode(tab),
mode: normalizeTabMode(tab.mode),
toolApprovalMode: normalizeToolApprovalMode(tab.toolApprovalMode),
goal: strings.TrimSpace(tab.goal),
}
a.mu.RUnlock()
if ctrl != nil {
snap.mode = tabModeFromAxes(ctrl.PlanMode(), ctrl.AutoApproveTools())
snap.toolApprovalMode = normalizeToolApprovalMode(ctrl.ToolApprovalMode())
if goal := strings.TrimSpace(ctrl.Goal()); goal != "" && ctrl.GoalStatus() == control.GoalStatusRunning {
snap.goal = goal
} else {
snap.goal = ""
}
}
return saveTabSessionMetaSnapshot(snap)
}
type tabSessionMetaSnapshot struct {
path, scope, workspaceRoot string
topicID, topicTitle string
tokenMode, qualityFloor string
mode, toolApprovalMode string
goal string
}
func (a *App) saveTabSessionMetaForCurrentSession(tab *WorkspaceTab) error {
snap, ok := a.tabSessionMetaSnapshotForCurrentSession(tab)
if !ok {
return nil
snap, ok, err := a.tabSessionMetaSnapshot(tab, path, false)
if err != nil || !ok {
return err
}
return a.saveTabSessionMetaSnapshotAndIndex(snap)
}
func (a *App) tabSessionMetaSnapshotForCurrentSession(tab *WorkspaceTab) (tabSessionMetaSnapshot, bool) {
type tabSessionMetaSnapshot struct {
path legacySessionPath
scope, workspaceRoot string
topicID, topicTitle string
tokenMode string
qualityFloor string
mode string
toolApprovalMode string
goal string
}
func (a *App) saveTabSessionMetaForCurrentSession(tab *WorkspaceTab) error {
snap, ok, err := a.tabSessionMetaSnapshotForCurrentSession(tab)
if err != nil || !ok {
return err
}
return a.saveTabSessionMetaSnapshotAndIndex(snap)
}
func (a *App) tabSessionMetaSnapshotForCurrentSession(tab *WorkspaceTab) (tabSessionMetaSnapshot, bool, error) {
return a.tabSessionMetaSnapshot(tab, "", true)
}
type tabSessionMetaSource struct {
snapshot tabSessionMetaSnapshot
ctrl control.SessionAPI
generation uint64
sessionID, storedPath string
readOnly bool
}
func (a *App) captureTabSessionMetaSource(tab *WorkspaceTab) (tabSessionMetaSource, bool) {
if tab == nil {
return tabSessionMetaSnapshot{}, false
return tabSessionMetaSource{}, false
}
a.mu.RLock()
defer a.mu.RUnlock()
if tab.ID != "" && a.tabs[tab.ID] != tab {
a.mu.RUnlock()
return tabSessionMetaSnapshot{}, false
}
readOnly := tab.ReadOnly
ctrl := tab.Ctrl
storedPath := strings.TrimSpace(tab.SessionPath)
scope := tab.Scope
workspaceRoot := tab.WorkspaceRoot
topicID := tab.TopicID
topicTitle := tab.TopicTitle
tokenMode := currentTabTokenMode(tab)
qualityFloor := control.QualityFloorStandard
mode := normalizeTabMode(tab.mode)
toolApprovalMode := normalizeToolApprovalMode(tab.toolApprovalMode)
goal := strings.TrimSpace(tab.goal)
a.mu.RUnlock()
if readOnly {
return tabSessionMetaSnapshot{}, false
return tabSessionMetaSource{}, false
}
return tabSessionMetaSource{
ctrl: tab.Ctrl,
generation: tab.SessionGeneration,
sessionID: strings.TrimSpace(tab.SessionID),
storedPath: strings.TrimSpace(tab.SessionPath),
readOnly: tab.ReadOnly,
snapshot: tabSessionMetaSnapshot{
scope: tab.Scope, workspaceRoot: tab.WorkspaceRoot,
topicID: tab.TopicID, topicTitle: tab.TopicTitle,
tokenMode: currentTabTokenMode(tab), qualityFloor: control.QualityFloorStandard,
mode: normalizeTabMode(tab.mode), toolApprovalMode: normalizeToolApprovalMode(tab.toolApprovalMode),
goal: strings.TrimSpace(tab.goal),
},
}, true
}
ctrlPath := ""
ctrlDir := ""
activeWork := false
if ctrl != nil {
ctrlPath = strings.TrimSpace(ctrl.SessionPath())
if dir, ok := safeControllerSessionDir(ctrl); ok {
func canonicalTabSessionMetaDisposition(sessionID, storedPath, requestedPath string) (bool, error) {
if sessionID == "" {
return false, nil
}
if err := session.ValidateSessionID(sessionID); err != nil {
return false, &sessionLocatorError{reason: "invalid_canonical_session_id"}
}
if locator := classifySessionLocator(storedPath); locator.kind != sessionLocatorEmpty {
if locator.kind != sessionLocatorCanonical || locator.ref.SessionID != sessionID {
return false, &sessionLocatorError{reason: "session_identity_conflict"}
}
}
if requestedPath != "" {
locator := classifySessionLocator(requestedPath)
if locator.kind == sessionLocatorInvalid || locator.kind == sessionLocatorCanonical && locator.ref.SessionID != sessionID {
return false, &sessionLocatorError{reason: "session_identity_conflict"}
}
}
// Canonical session metadata belongs to the Session Service, workspace
// registry, and desktop-tabs.json. Never dual-write a legacy sidecar.
return true, nil
}
func updateTabSessionMetaFromController(source *tabSessionMetaSource) (ctrlPath, ctrlDir string, activeWork bool) {
if source.ctrl != nil {
ctrlPath = strings.TrimSpace(source.ctrl.SessionPath())
if dir, ok := safeControllerSessionDir(source.ctrl); ok {
ctrlDir = strings.TrimSpace(dir)
}
status := ctrl.RuntimeStatus()
status := source.ctrl.RuntimeStatus()
activeWork = status.Running || status.PendingPrompt || status.BackgroundJobs > 0
mode = tabModeFromAxes(ctrl.PlanMode(), ctrl.AutoApproveTools())
toolApprovalMode = normalizeToolApprovalMode(ctrl.ToolApprovalMode())
if ctrl.GoalStatus() == control.GoalStatusRunning {
goal = strings.TrimSpace(ctrl.Goal())
source.snapshot.mode = tabModeFromAxes(source.ctrl.PlanMode(), source.ctrl.AutoApproveTools())
source.snapshot.toolApprovalMode = normalizeToolApprovalMode(source.ctrl.ToolApprovalMode())
if source.ctrl.GoalStatus() == control.GoalStatusRunning {
source.snapshot.goal = strings.TrimSpace(source.ctrl.Goal())
} else {
goal = ""
source.snapshot.goal = ""
}
}
return ctrlPath, ctrlDir, activeWork
}
currentPath := ctrlPath
if currentPath == "" {
currentPath = storedPath
func tabSessionMetaLegacyPath(source tabSessionMetaSource, requestedPath string, useCurrent bool, ctrlPath, ctrlDir string, activeWork bool) (legacySessionPath, bool, error) {
currentPath := strings.TrimSpace(requestedPath)
if useCurrent {
currentPath = ctrlPath
if currentPath == "" {
currentPath = source.storedPath
}
}
if currentPath == "" {
return tabSessionMetaSnapshot{}, false
return "", false, nil
}
locator := classifySessionLocator(currentPath)
switch locator.kind {
case sessionLocatorCanonical:
return "", false, nil
case sessionLocatorInvalid:
return "", false, &sessionLocatorError{reason: locator.reason}
case sessionLocatorEmpty:
return "", false, nil
}
sessionDir := desktopSessionDir("")
if workspaceRoot != "" {
sessionDir = desktopSessionDir(workspaceRoot)
if source.snapshot.workspaceRoot != "" {
sessionDir = desktopSessionDir(source.snapshot.workspaceRoot)
} else if ctrlDir != "" {
sessionDir = ctrlDir
}
@@ -7278,46 +7340,67 @@ func (a *App) tabSessionMetaSnapshotForCurrentSession(tab *WorkspaceTab) (tabSes
runtimeDir = ctrlDir
}
}
if topicID == "" && !activeWork && storedPath != "" && sessionPathHasNoContent(sessionDir, storedPath) {
return tabSessionMetaSnapshot{}, false
if source.snapshot.topicID == "" && !activeWork && source.storedPath != "" && classifySessionLocator(source.storedPath).kind == sessionLocatorLegacy && sessionPathHasNoContent(sessionDir, source.storedPath) {
return "", false, nil
}
path := tabSessionMetaPathForSession(runtimeDir, sessionDir, currentPath)
if path == "" {
return tabSessionMetaSnapshot{}, false
path, err := tabSessionMetaPathForSession(runtimeDir, sessionDir, currentPath)
if err != nil {
return "", false, err
}
return tabSessionMetaSnapshot{
path: path,
scope: scope,
workspaceRoot: workspaceRoot,
topicID: topicID,
topicTitle: topicTitle,
tokenMode: tokenMode,
qualityFloor: qualityFloor,
mode: mode,
toolApprovalMode: toolApprovalMode,
goal: goal,
}, true
return path, true, nil
}
func (a *App) tabSessionMetaSourceCurrent(tab *WorkspaceTab, source tabSessionMetaSource) bool {
a.mu.RLock()
defer a.mu.RUnlock()
current := tab.ID == "" || a.tabs[tab.ID] == tab
return current && tab.Ctrl == source.ctrl && tab.SessionGeneration == source.generation &&
strings.TrimSpace(tab.SessionID) == source.sessionID && strings.TrimSpace(tab.SessionPath) == source.storedPath
}
func (a *App) tabSessionMetaSnapshot(tab *WorkspaceTab, requestedPath string, useCurrent bool) (tabSessionMetaSnapshot, bool, error) {
source, ok := a.captureTabSessionMetaSource(tab)
if !ok || source.readOnly {
return tabSessionMetaSnapshot{}, false, nil
}
canonical, err := canonicalTabSessionMetaDisposition(source.sessionID, source.storedPath, requestedPath)
if err != nil || canonical {
return tabSessionMetaSnapshot{}, false, err
}
ctrlPath, ctrlDir, activeWork := updateTabSessionMetaFromController(&source)
path, ok, err := tabSessionMetaLegacyPath(source, requestedPath, useCurrent, ctrlPath, ctrlDir, activeWork)
if err != nil || !ok {
return tabSessionMetaSnapshot{}, false, err
}
// Controller reads above are intentionally off App.mu. Fence the result
// before it can select a file target for a tab that has since switched.
if !a.tabSessionMetaSourceCurrent(tab, source) {
return tabSessionMetaSnapshot{}, false, nil
}
source.snapshot.path = path
return source.snapshot, true, nil
}
func saveTabSessionMetaSnapshot(snap tabSessionMetaSnapshot) error {
if strings.TrimSpace(snap.path) == "" {
path := string(snap.path)
if strings.TrimSpace(path) == "" {
return nil
}
// Read-modify-write on the branch-meta sidecar: hold the per-path meta lock
// so agent-side writers (autosave UpdateSessionMeta, in-flight markers)
// can't interleave and drop fields.
unlock, err := agent.LockSessionMetaPath(snap.path)
unlock, err := agent.LockSessionMetaPath(path)
if err != nil {
return err
}
defer unlock()
m, err := agent.EnsureBranchMetaLocked(snap.path)
m, err := agent.EnsureBranchMetaLocked(path)
if err != nil {
return err
}
scope := snap.scope
workspaceRoot := snap.workspaceRoot
if ownerScope, ownerRoot, _, ok := legacyMigrationTargetForDir(filepath.Dir(snap.path)); ok {
if ownerScope, ownerRoot, _, ok := legacyMigrationTargetForDir(filepath.Dir(path)); ok {
if ownerScope == "project" {
scope = ownerScope
workspaceRoot = ownerRoot
@@ -7337,28 +7420,15 @@ func saveTabSessionMetaSnapshot(snap tabSessionMetaSnapshot) error {
m.Mode = persistedTabMode(snap.mode)
m.ToolApprovalMode = persistedToolApprovalMode(snap.toolApprovalMode)
m.Goal = strings.TrimSpace(snap.goal)
if err := agent.SaveBranchMetaPreserveUpdatedLocked(snap.path, m); err != nil {
if err := agent.SaveBranchMetaPreserveUpdatedLocked(path, m); err != nil {
return err
}
invalidateTopicSessionIndexForPath(snap.path)
invalidateTopicSessionIndexForPath(path)
return nil
}
func tabSessionMetaPathForSession(runtimeDir, sessionDir, sessionPath string) string {
sessionPath = strings.TrimSpace(sessionPath)
if sessionPath == "" {
return ""
}
for _, dir := range []string{runtimeDir, sessionDir} {
if resolved, ok := pinnedTabSessionPath(dir, sessionPath); ok {
return resolved
}
}
path := canonicalTabSessionPath(sessionPath)
if filepath.IsAbs(path) {
return path
}
return ""
func tabSessionMetaPathForSession(runtimeDir, sessionDir, sessionPath string) (legacySessionPath, error) {
return resolveLegacySessionPath(sessionPath, runtimeDir, sessionDir)
}
type tabSessionProfile struct {
@@ -7390,6 +7460,11 @@ func tabSessionProfileFromMeta(sessionPath string, meta agent.BranchMeta) tabSes
}
func loadTabSessionProfile(sessionPath string) tabSessionProfile {
legacyPath, valid := validatedLegacySessionPathForRead(sessionPath)
if !valid {
return defaultTabSessionProfile()
}
sessionPath = string(legacyPath)
meta, ok, err := agent.LoadBranchMeta(sessionPath)
if err != nil || !ok {
return defaultTabSessionProfile()
@@ -7429,6 +7504,11 @@ func runningTabSessionGoal(sessionPath, fallback string) string {
if fallback == "" {
return ""
}
legacyPath, ok := validatedLegacySessionPathForRead(sessionPath)
if !ok {
return fallback
}
sessionPath = string(legacyPath)
data, err := readFileUTF8(store.SessionGoalState(sessionPath))
if err != nil {
return fallback
@@ -7451,42 +7531,60 @@ func runningTabSessionGoal(sessionPath, fallback string) string {
}
func canonicalTabSessionPath(path string) string {
path = strings.TrimSpace(path)
if path == "" {
locator := classifySessionLocator(path)
if locator.kind != sessionLocatorLegacy {
return ""
}
path = string(locator.legacyPath)
if validPath, _, err := validateSessionPath(config.SessionDir(), path); err == nil {
return validPath
}
// Project-scope sessions live outside config.SessionDir(), so validation
// against it always fails. Still normalize the shape: without clean+abs,
// the same file spelled with a different separator or a relative prefix
// splits into distinct runtime keys.
cleaned := filepath.Clean(path)
if abs, err := filepath.Abs(cleaned); err == nil {
return abs
// Project-scope sessions live outside config.SessionDir(). Their absolute
// transcript path still has to pass the same filename and link-escape
// checks before it can reach runtime identity or file helpers.
if filepath.IsAbs(path) {
if validPath, _, err := validateSessionPath(filepath.Dir(path), path); err == nil {
return validPath
}
}
return cleaned
return ""
}
func (a *App) rememberTabSessionPath(tab *WorkspaceTab, path string) {
path = canonicalTabSessionPath(path)
if tab == nil || path == "" {
if tab == nil {
return
}
locator := classifySessionLocator(path)
if locator.kind == sessionLocatorInvalid || locator.kind == sessionLocatorEmpty {
return
}
if locator.kind == sessionLocatorLegacy {
path = canonicalTabSessionPath(path)
if path == "" {
return
}
}
a.mu.Lock()
if current := a.tabs[tab.ID]; current == tab {
tab.SessionPath = path
setTabSessionIdentity(tab, path)
a.saveTabsLocked()
} else {
tab.SessionPath = path
setTabSessionIdentity(tab, path)
}
a.mu.Unlock()
}
func (a *App) persistTabSessionPath(tab *WorkspaceTab, path string) {
locator := classifySessionLocator(path)
if tab == nil || locator.kind == sessionLocatorEmpty || locator.kind == sessionLocatorInvalid {
return
}
if locator.kind == sessionLocatorCanonical {
a.rememberTabSessionPath(tab, sessionRoute(locator.ref.SessionID))
return
}
path = canonicalTabSessionPath(path)
if tab == nil || path == "" {
if path == "" {
return
}
// A tab restored from the short-lived tab-scoped implementation may not
+4 -2
View File
@@ -29,8 +29,10 @@ type desktopTabEntry struct {
ToolApprovalMode string `json:"toolApprovalMode,omitempty"`
// PinnedFiles is read-only upgrade input from the unmerged tab-scoped
// implementation. New writers persist pins beside the owning session.
PinnedFiles []string `json:"pinnedFiles,omitempty"`
extra map[string]json.RawMessage
PinnedFiles []string `json:"pinnedFiles,omitempty"`
extra map[string]json.RawMessage
restoreBlocked bool
restoreBlockReason string
}
type desktopTabsFile struct {
-114
View File
@@ -3021,120 +3021,6 @@ func (c *Controller) NewSession() error {
return nil
}
// ClearSession discards the current conversation without preserving it in
// resume/history, then rotates to a clean session carrying the same base system
// prompt and no pinned context.
func (c *Controller) ClearSession() error {
if c.executor == nil {
return nil
}
// Same rotation gate as NewSession: hold it across the whole
// destroy-then-swap so a turn cannot start during the sequence and have its
// live session replaced.
if err := c.beginRotation(); err != nil {
if errors.Is(err, errTurnRunningRotation) {
return fmt.Errorf("cannot clear while a turn is running: %w", err)
}
return err
}
defer c.endRotation()
if c.sessionEngineEnabled() {
return c.rotateExclusiveSession(true)
}
c.mu.Lock()
oldPath := c.sessionPath
c.mu.Unlock()
preMarkedCleanup := c.hasUnfinishedSessionJobs(oldPath)
if preMarkedCleanup {
if err := agent.MarkCleanupPending(oldPath, "clear"); err != nil {
return err
}
}
// Retire the old recovery state before deleting its artifacts. Async gate
// snapshots are path-bound, so wait for every already-scheduled old-path
// write; otherwise one can recreate the sidecar after removeSessionArtifacts.
c.loadRecoveryState("")
c.flushRecoveryPersistence(oldPath)
// session.rotate: the session_policy owner rules on the rotation before any
// artifact is destroyed, so its failure (required-class) aborts the clear
// with the old session fully intact. SessionPath is the file being rotated
// away from; the fresh path arrives with the session.start event below.
if err := c.extensionSessionPhase(context.Background(), extension.PointSessionRotate, dispatch.PhaseRotate, oldPath); err != nil {
return err
}
// Hold snapshotMu from artifact removal through the swap: a save slipping
// in between would resurrect the just-removed transcript, and one that
// overlapped the swap could pair the old path with the fresh session.
c.snapshotMu.Lock()
c.detachInboxForDiscard(oldPath)
destroy := c.BeginDestroySession(oldPath)
if !destroy.Async {
if err := removeSessionArtifacts(oldPath); err != nil {
destroy.Finish()
c.snapshotMu.Unlock()
c.rebindInbox()
return err
}
destroy.Finish()
}
freshPath := oldPath
if c.sessionDir != "" {
freshPath = agent.NewSessionPath(c.sessionDir, c.label)
}
freshSession := agent.NewSession(c.basePrompt())
commitTransition, err := c.prepareSessionTransition(freshPath, "clear", freshSession)
if err != nil {
if destroy.Async {
destroy.Finish()
}
c.snapshotMu.Unlock()
c.rebindInbox()
return fmt.Errorf("bind cleared session: %w", err)
}
c.hooks.SessionEnd(context.Background(), "clear")
c.extensionSessionEvent(extension.PointSessionEnd, dispatch.PhaseEnd, oldPath)
commitTransition.publish()
c.bindExecutorProjection(c.SessionPath(), false)
if c.guardianSess != nil {
c.guardianSess.Reset()
}
c.ResetPlannerSession()
c.rebindCheckpoints(freshPath)
seedErr := c.seedSessionEventsFromExecutor("session-clear")
c.resetRecoveryForNewSession(freshPath)
c.rotateSessionTemp()
c.snapshotMu.Unlock()
c.rebindInbox()
// Same contract as NewSession: the fresh session starts with no active goal.
c.ClearGoal()
c.mu.Lock()
c.startedOnce = true
c.mu.Unlock()
c.hooks.SetSessionID(c.parentSessionID())
c.enqueueHookContexts(c.hooks.SessionStart(context.Background(), "clear"))
c.extensionSessionEvent(extension.PointSessionStart, dispatch.PhaseStart, c.SessionPath())
c.clearSessionWriteAccess()
if destroy.Async {
go func() {
result := destroy.Wait()
if result.HasTimedOut() && destroy.WaitAll != nil {
if err := agent.MarkCleanupPending(oldPath, "clear"); err != nil {
c.sink.Emit(event.Event{Kind: event.Notice, Level: event.LevelWarn, Text: "mark cleanup pending failed: " + err.Error()})
}
destroy.WaitAll()
}
if err := removeSessionArtifacts(oldPath); err != nil {
c.sink.Emit(event.Event{Kind: event.Notice, Level: event.LevelWarn, Text: "clear session cleanup failed: " + err.Error()})
}
destroy.Finish()
}()
}
if seedErr != nil {
return fmt.Errorf("seed cleared session events: %w", seedErr)
}
return nil
}
func (c *Controller) hasUnfinishedSessionJobs(sessionPath string) bool {
if c.jobs == nil {
return false
-47
View File
@@ -2782,53 +2782,6 @@ func TestTwoModelShortChoiceReplySkipsPlanner(t *testing.T) {
}
}
func TestSubmitClearDiscardsCurrentContextWithoutSavingTranscript(t *testing.T) {
dir := t.TempDir()
sess := agent.NewSession("sys")
sess.Add(provider.Message{Role: provider.RoleUser, Content: "old context"})
exec := agent.New(nil, nil, sess, agent.Options{}, event.Discard)
path := filepath.Join(dir, "session.jsonl")
cleared := make(chan struct{})
sink := event.FuncSink(func(e event.Event) {
if e.Kind == event.Notice && e.Text == "context cleared" {
close(cleared)
}
})
c := newOwnedTestController(t, Options{Executor: exec, SystemPrompt: "sys", SessionDir: dir, SessionPath: path, Label: "test", Sink: sink})
if err := c.Snapshot(); err != nil {
t.Fatal(err)
}
ckpt := ckptDir(path)
if err := os.MkdirAll(ckpt, 0o755); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(ckpt, "turn-0.json"), []byte("{}"), 0o644); err != nil {
t.Fatal(err)
}
c.submit("/clear", "", "")
select {
case <-cleared:
case <-time.After(30 * time.Second):
t.Fatal("/clear did not finish")
}
if c.SessionPath() == path {
t.Fatal("/clear did not rotate to a fresh session path")
}
for _, p := range []string{path, agent.BranchMetaPath(path), ckpt} {
if _, err := os.Stat(p); !os.IsNotExist(err) {
t.Fatalf("discarded artifact %s still exists or stat failed with %v", p, err)
}
}
if _, err := os.Stat(c.SessionPath()); !os.IsNotExist(err) {
t.Fatalf("fresh empty session should not be saved yet; stat err=%v", err)
}
current := exec.Session().Snapshot()
if len(current) != 1 || current[0].Role != provider.RoleSystem || current[0].Content != "sys" {
t.Fatalf("cleared context = %+v, want only system prompt", current)
}
}
func TestDisconnectMCPServerRemovesLazyPlaceholder(t *testing.T) {
reg := tool.NewRegistry()
reg.Add(fakeControlTool{name: "mcp__mock__connect"})
-16
View File
@@ -307,22 +307,6 @@ func (c *Controller) rebindInbox() {
}
}
// detachInboxForDiscard releases the old inbox transaction lock before a
// destructive session clear. Windows does not permit removing an open lock
// file, while Unix silently unlinks it, so the ordering must be explicit.
func (c *Controller) detachInboxForDiscard(sessionPath string) {
c.inbox.scanMu.Lock()
defer c.inbox.scanMu.Unlock()
c.inbox.mu.Lock()
defer c.inbox.mu.Unlock()
if c.inbox.store == nil || c.inbox.store.SessionPath() != sessionPath {
return
}
c.inbox.store.Close()
c.inbox.store = nil
c.inbox.clearActive()
}
func (c *Controller) pauseInboxOnRotate() {
c.inbox.mu.Lock()
st := c.inbox.store
+140
View File
@@ -0,0 +1,140 @@
package control
import (
"context"
"errors"
"fmt"
"reasonix/internal/agent"
"reasonix/internal/event"
"reasonix/internal/extension"
"reasonix/internal/extension/dispatch"
)
// ClearSession discards the current conversation without preserving it in
// resume/history, then rotates to a clean session carrying the same base system
// prompt and no pinned context.
func (c *Controller) ClearSession() error {
if c.executor == nil {
return nil
}
// Same rotation gate as NewSession: hold it across the whole
// destroy-then-swap so a turn cannot start during the sequence and have its
// live session replaced.
if err := c.beginRotation(); err != nil {
if errors.Is(err, errTurnRunningRotation) {
return fmt.Errorf("cannot clear while a turn is running: %w", err)
}
return err
}
defer c.endRotation()
if c.sessionEngineEnabled() {
return c.rotateExclusiveSession(true)
}
c.mu.Lock()
oldPath := c.sessionPath
c.mu.Unlock()
preMarkedCleanup := c.hasUnfinishedSessionJobs(oldPath)
if preMarkedCleanup {
if err := agent.MarkCleanupPending(oldPath, "clear"); err != nil {
return err
}
}
// Retire the old recovery state before deleting its artifacts. Async gate
// snapshots are path-bound, so wait for every already-scheduled old-path
// write; otherwise one can recreate the sidecar after removeSessionArtifacts.
c.loadRecoveryState("")
c.flushRecoveryPersistence(oldPath)
// Let session_policy rule before destroying artifacts. A required failure
// keeps the old session intact; the fresh path arrives with session.start.
if err := c.extensionSessionPhase(context.Background(), extension.PointSessionRotate, dispatch.PhaseRotate, oldPath); err != nil {
return err
}
// Hold snapshotMu from artifact removal through the swap: a save slipping
// in between would resurrect the just-removed transcript, and one that
// overlapped the swap could pair the old path with the fresh session.
c.snapshotMu.Lock()
c.detachInboxForDiscard(oldPath)
destroy := c.BeginDestroySession(oldPath)
if !destroy.Async {
if err := removeSessionArtifacts(oldPath); err != nil {
destroy.Finish()
c.snapshotMu.Unlock()
c.rebindInbox()
return err
}
destroy.Finish()
}
freshPath := oldPath
if c.sessionDir != "" {
freshPath = agent.NewSessionPath(c.sessionDir, c.label)
}
freshSession := agent.NewSession(c.basePrompt())
commitTransition, err := c.prepareSessionTransition(freshPath, "clear", freshSession)
if err != nil {
if destroy.Async {
destroy.Finish()
}
c.snapshotMu.Unlock()
c.rebindInbox()
return fmt.Errorf("bind cleared session: %w", err)
}
c.hooks.SessionEnd(context.Background(), "clear")
c.extensionSessionEvent(extension.PointSessionEnd, dispatch.PhaseEnd, oldPath)
commitTransition.publish()
c.bindExecutorProjection(c.SessionPath(), false)
if c.guardianSess != nil {
c.guardianSess.Reset()
}
c.ResetPlannerSession()
c.rebindCheckpoints(freshPath)
seedErr := c.seedSessionEventsFromExecutor("session-clear")
c.resetRecoveryForNewSession(freshPath)
c.rotateSessionTemp()
c.snapshotMu.Unlock()
c.rebindInbox()
// Same contract as NewSession: the fresh session starts with no active goal.
c.ClearGoal()
c.mu.Lock()
c.startedOnce = true
c.mu.Unlock()
c.hooks.SetSessionID(c.parentSessionID())
c.enqueueHookContexts(c.hooks.SessionStart(context.Background(), "clear"))
c.extensionSessionEvent(extension.PointSessionStart, dispatch.PhaseStart, c.SessionPath())
c.clearSessionWriteAccess()
if destroy.Async {
go func() {
result := destroy.Wait()
if result.HasTimedOut() && destroy.WaitAll != nil {
if err := agent.MarkCleanupPending(oldPath, "clear"); err != nil {
c.sink.Emit(event.Event{Kind: event.Notice, Level: event.LevelWarn, Text: "mark cleanup pending failed: " + err.Error()})
}
destroy.WaitAll()
}
if err := removeSessionArtifacts(oldPath); err != nil {
c.sink.Emit(event.Event{Kind: event.Notice, Level: event.LevelWarn, Text: "clear session cleanup failed: " + err.Error()})
}
destroy.Finish()
}()
}
if seedErr != nil {
return fmt.Errorf("seed cleared session events: %w", seedErr)
}
return nil
}
// detachInboxForDiscard releases the old inbox transaction lock before a
// destructive session clear. Windows does not permit removing an open lock
// file, while Unix silently unlinks it, so the ordering must be explicit.
func (c *Controller) detachInboxForDiscard(sessionPath string) {
c.inbox.scanMu.Lock()
defer c.inbox.scanMu.Unlock()
c.inbox.mu.Lock()
defer c.inbox.mu.Unlock()
if c.inbox.store == nil || c.inbox.store.SessionPath() != sessionPath {
return
}
c.inbox.store.Close()
c.inbox.store = nil
c.inbox.clearActive()
}
+70
View File
@@ -0,0 +1,70 @@
package control
import (
"os"
"path/filepath"
"runtime"
"strings"
"testing"
"time"
"reasonix/internal/agent"
"reasonix/internal/event"
"reasonix/internal/provider"
)
func TestSubmitClearDiscardsCurrentContextWithoutSavingTranscript(t *testing.T) {
dir := t.TempDir()
sess := agent.NewSession("sys")
sess.Add(provider.Message{Role: provider.RoleUser, Content: "old context"})
exec := agent.New(nil, nil, sess, agent.Options{}, event.Discard)
path := filepath.Join(dir, "session.jsonl")
clearResult := make(chan string, 1)
sink := event.FuncSink(func(e event.Event) {
if e.Kind != event.Notice || (e.Text != "context cleared" && !strings.HasPrefix(e.Text, "clear context failed: ")) {
return
}
select {
case clearResult <- e.Text:
default:
}
})
c := newOwnedTestController(t, Options{Executor: exec, SystemPrompt: "sys", SessionDir: dir, SessionPath: path, Label: "test", Sink: sink})
if err := c.Snapshot(); err != nil {
t.Fatal(err)
}
ckpt := ckptDir(path)
if err := os.MkdirAll(ckpt, 0o755); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(ckpt, "turn-0.json"), []byte("{}"), 0o644); err != nil {
t.Fatal(err)
}
c.submit("/clear", "", "")
select {
case result := <-clearResult:
if result != "context cleared" {
t.Fatal(result)
}
case <-time.After(30 * time.Second):
stack := make([]byte, 2<<20)
n := runtime.Stack(stack, true)
t.Fatalf("/clear did not finish\n%s", stack[:n])
}
if c.SessionPath() == path {
t.Fatal("/clear did not rotate to a fresh session path")
}
for _, p := range []string{path, agent.BranchMetaPath(path), ckpt} {
if _, err := os.Stat(p); !os.IsNotExist(err) {
t.Fatalf("discarded artifact %s still exists or stat failed with %v", p, err)
}
}
if _, err := os.Stat(c.SessionPath()); !os.IsNotExist(err) {
t.Fatalf("fresh empty session should not be saved yet; stat err=%v", err)
}
current := exec.Session().Snapshot()
if len(current) != 1 || current[0].Role != provider.RoleSystem || current[0].Content != "sys" {
t.Fatalf("cleared context = %+v, want only system prompt", current)
}
}
+7
View File
@@ -373,6 +373,13 @@ func validateSessionID(id string) error {
return nil
}
// ValidateSessionID applies the canonical session storage identity rules.
// Callers that accept compatibility routes must validate the extracted ID
// before deciding whether the input names a SessionRef or a legacy path.
func ValidateSessionID(id string) error {
return validateSessionID(id)
}
type readHandle struct {
id string
dir string