mirror of
https://github.com/esengine/DeepSeek-Reasonix.git
synced 2026-11-07 17:10:43 -10:00
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:
24 files changed
+1579
-383
No files matched your search
@@ -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
@@ -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 {
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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"}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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[:])
|
||||
}
|
||||
|
||||
|
||||
@@ -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
@@ -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
@@ -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
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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"})
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
Reference in new issue
Block a user