fix(sync): route sessions by server-confirmed directory, unstick queued sends
Session directory resolution had no precedence contract: the selection-time directory short-circuited every lookup, and a persisted runtime value was consulted before the authoritative record. A worktree session selected before its directory store bootstrapped kept the active-directory fallback, and that guess was persisted, so it survived reloads and restarts. Directory resolution now lives in one module and orders sources by whether the server confirmed the path, not by whether the value is local or synced: authoritative (the child store that holds the session) > server-confirmed selection > worktree attachment/metadata (the requested path, pre-canonical) > remembered. A guessed selection is no longer persisted, remembered, or ranked. Chips read the same resolution the composer used, so queue keys cannot diverge. Queued auto-send could strand an item indefinitely: backoff, missing send configuration, and the recent-abort window all returned without scheduling a wake-up, so the queue only retried when an unrelated status or directory change re-ran the effect. A retry scheduler now wakes it at the earliest known time. A rejected send rolls the optimistic message back while the composer stays silent for transport failures, which makes it indistinguishable from nothing happening. Failures are now recorded to a bounded in-memory log surfaced in the About diagnostics report, alongside a directory-resolution breakdown, plus __opencodeDebug.diagnoseSessionDirectory() and getRecentSendFailures(). Prompted by a report of worktree prompting silently failing. That failure was not reproduced locally, so the diagnostics are what will identify it.
This commit is contained in:
@@ -40,7 +40,13 @@ import {
|
||||
getSyncMessages,
|
||||
getSyncParts,
|
||||
getDirectoryState,
|
||||
getSyncSessionDirectory,
|
||||
} from "./sync-refs"
|
||||
import {
|
||||
resolveSessionDirectoryFromSources,
|
||||
type SessionDirectoryResolution,
|
||||
type SessionDirectorySources,
|
||||
} from "./session-directory-resolution"
|
||||
import { markSessionViewed } from "./notification-store"
|
||||
import { setActiveSession } from "./sync-context"
|
||||
import {
|
||||
@@ -73,7 +79,7 @@ import { useSessionWorktreeStore } from "./session-worktree-store"
|
||||
import { getAttachedSessionDirectory } from "./session-worktree-contract"
|
||||
import { setSessionOpener } from "./session-navigation"
|
||||
import { getRuntimeKey } from "@/lib/runtime-switch"
|
||||
import { clearLastActiveSession, persistLastActiveSession } from "./last-session-cache"
|
||||
import { clearLastActiveSession, persistLastActiveSession, readLastActiveSession } from "./last-session-cache"
|
||||
import { persistWorktreeTopology, readPersistedWorktreeTopology } from "./worktree-topology-cache"
|
||||
import { rememberRuntimeLiveStatus } from "./runtime-live-memory"
|
||||
|
||||
@@ -394,23 +400,108 @@ const getAttachmentForSession = (sessionId: string | null | undefined): SessionW
|
||||
return useSessionWorktreeStore.getState().getAttachment(sessionId)
|
||||
}
|
||||
|
||||
/**
|
||||
* Authoritative directory for a session: the child store that holds it, and
|
||||
* only then the session record's own fields. `null` means "not indexed yet",
|
||||
* never "no directory" — callers must fall back rather than treat it as empty.
|
||||
*/
|
||||
const getAuthoritativeSessionDirectory = (sessionId: string): string | null => {
|
||||
const owningDirectory = getSyncSessionDirectory(sessionId)
|
||||
if (owningDirectory) return normalizePath(owningDirectory)
|
||||
const target = getAllSyncSessions().find((s) => s.id === sessionId)
|
||||
return target ? resolveDirectoryKey(target) : null
|
||||
}
|
||||
|
||||
/**
|
||||
* Directory remembered for a session in this runtime, plus the one persisted
|
||||
* across restarts. Exported for diagnostics: a stale persisted directory is the
|
||||
* hardest source to observe and the one that survives reloads, so a report that
|
||||
* cannot show it cannot rule it out.
|
||||
*/
|
||||
export const getRememberedSessionDirectory = (sessionId: string): {
|
||||
runtime: string | null
|
||||
persisted: string | null
|
||||
} => {
|
||||
const key = runtimeMemoryKey()
|
||||
const runtimeMemory = runtimeSessionMemory.get(key)
|
||||
const persisted = readLastActiveSession(key)
|
||||
return {
|
||||
runtime: runtimeMemory?.sessionId === sessionId ? normalizePath(runtimeMemory.directory) : null,
|
||||
persisted: persisted?.sessionId === sessionId ? normalizePath(persisted.directory) : null,
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Session whose `currentSessionDirectory` is only the active directory, used
|
||||
* because the session's own directory was not known at selection time. Such a
|
||||
* value must never outrank a worktree assignment or reach persistence — it is
|
||||
* a guess, not a selection.
|
||||
*/
|
||||
let guessedSelectionSessionId: string | null = null
|
||||
|
||||
const collectSessionDirectorySources = (
|
||||
sessionId: string,
|
||||
getWtMeta: (id: string) => WorktreeMetadata | undefined,
|
||||
selected: string | null,
|
||||
): SessionDirectorySources => ({
|
||||
authoritative: getAuthoritativeSessionDirectory(sessionId),
|
||||
selected: sessionId === guessedSelectionSessionId ? null : normalizePath(selected),
|
||||
attachment: getAttachedSessionDirectory(getAttachmentForSession(sessionId)),
|
||||
worktreeMetadata: normalizePath(getWtMeta(sessionId)?.path ?? null),
|
||||
remembered: getRememberedSessionDirectory(sessionId).runtime,
|
||||
})
|
||||
|
||||
/**
|
||||
* Conflicts already warned about, so a stale directory logs once instead of on
|
||||
* every keystroke. Keyed by runtime *and* the exact pair of directories: the
|
||||
* same session ID means a different thing in another runtime, and a conflict
|
||||
* that reappears after being resolved is news worth logging again. Bounded so
|
||||
* a long-lived session cannot grow it without limit.
|
||||
*/
|
||||
const reportedDirectoryConflicts = new Set<string>()
|
||||
const MAX_REPORTED_DIRECTORY_CONFLICTS = 200
|
||||
|
||||
const reportSessionDirectoryConflict = (
|
||||
sessionId: string,
|
||||
resolution: SessionDirectoryResolution,
|
||||
): void => {
|
||||
if (!resolution.conflict) return
|
||||
const conflictKey = JSON.stringify([
|
||||
runtimeMemoryKey(),
|
||||
sessionId,
|
||||
resolution.directory,
|
||||
resolution.conflict.source,
|
||||
resolution.conflict.directory,
|
||||
])
|
||||
if (reportedDirectoryConflicts.has(conflictKey)) return
|
||||
if (reportedDirectoryConflicts.size >= MAX_REPORTED_DIRECTORY_CONFLICTS) {
|
||||
reportedDirectoryConflicts.clear()
|
||||
}
|
||||
reportedDirectoryConflicts.add(conflictKey)
|
||||
console.warn(
|
||||
"[session-directory] session directory sources disagree; using the higher-authority one. "
|
||||
+ "Run __opencodeDebug.diagnoseSessionDirectory() for the full picture.",
|
||||
{
|
||||
sessionId,
|
||||
using: resolution.source,
|
||||
directory: resolution.directory,
|
||||
conflictingSource: resolution.conflict.source,
|
||||
conflictingDirectory: resolution.conflict.directory,
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
const resolveSessionDirectory = (
|
||||
sessionId: string | null | undefined,
|
||||
getWtMeta: (id: string) => WorktreeMetadata | undefined,
|
||||
selected: string | null = null,
|
||||
): string | null => {
|
||||
if (!sessionId) return null
|
||||
const attachmentDirectory = getAttachedSessionDirectory(getAttachmentForSession(sessionId))
|
||||
if (attachmentDirectory) return attachmentDirectory
|
||||
const metaPath = getWtMeta(sessionId)?.path
|
||||
if (typeof metaPath === "string" && metaPath.trim().length > 0) return normalizePath(metaPath)
|
||||
const runtimeMemory = runtimeSessionMemory.get(runtimeMemoryKey())
|
||||
if (runtimeMemory?.sessionId === sessionId && runtimeMemory.directory) {
|
||||
return normalizePath(runtimeMemory.directory)
|
||||
}
|
||||
const sessions = getAllSyncSessions()
|
||||
const target = sessions.find((s) => s.id === sessionId)
|
||||
if (!target) return null
|
||||
return resolveDirectoryKey(target)
|
||||
const resolution = resolveSessionDirectoryFromSources(
|
||||
collectSessionDirectorySources(sessionId, getWtMeta, selected),
|
||||
)
|
||||
reportSessionDirectoryConflict(sessionId, resolution)
|
||||
return resolution.directory
|
||||
}
|
||||
|
||||
const activateConfigForDirectory = async (directory: string | null | undefined): Promise<void> => {
|
||||
@@ -504,13 +595,18 @@ export async function materializeOpenDraftSession(selection: {
|
||||
const created = await store.createSession(draft.title, draftDirectoryOverride, draft.parentID ?? null)
|
||||
if (!created?.id) throw new Error("Failed to create session")
|
||||
|
||||
// The server response is authoritative. It may canonicalize a requested
|
||||
// worktree path (for example through a symlink or platform path casing).
|
||||
// Sending with the pre-canonical draft path can target a different
|
||||
// directory scope than the session that was just created.
|
||||
const createdDirectory = normalizePath(created.directory ?? draftDirectoryOverride ?? null)
|
||||
|
||||
persistDraftTarget({
|
||||
projectId: draftProjectId,
|
||||
directory: normalizePath(draftDirectoryOverride ?? created.directory ?? null),
|
||||
directory: createdDirectory,
|
||||
})
|
||||
|
||||
const draftSyntheticParts = draft.syntheticParts
|
||||
const createdDirectory = normalizePath(draftDirectoryOverride ?? created.directory ?? null)
|
||||
const configState = useConfigStore.getState()
|
||||
void activateConfigForDirectory(createdDirectory).catch((error) => {
|
||||
console.warn("Failed to activate directory after creating session:", error)
|
||||
@@ -604,7 +700,13 @@ export const useSessionUIStore = create<SessionUIState>()((set, get) => ({
|
||||
(sid) => get().worktreeMetadata.get(sid),
|
||||
)
|
||||
const fallbackDir = opencodeClient.getDirectory() ?? directoryState.currentDirectory ?? null
|
||||
const resolvedDir = (directoryHint ? normalizePath(directoryHint) : null) ?? sessionDir ?? fallbackDir
|
||||
const knownDir = (directoryHint ? normalizePath(directoryHint) : null) ?? sessionDir
|
||||
const resolvedDir = knownDir ?? fallbackDir
|
||||
// `fallbackDir` is the active directory, not this session's directory. It
|
||||
// keeps routing usable while the owning directory store bootstraps, but it
|
||||
// must never be remembered: a persisted guess outlives the race that
|
||||
// produced it and survives reloads and restarts.
|
||||
const isGuessedDir = knownDir === null
|
||||
const projectsState = useProjectsStore.getState()
|
||||
const sessionProject = resolvedDir
|
||||
? resolveProjectForSessionDirectory(
|
||||
@@ -617,12 +719,14 @@ export const useSessionUIStore = create<SessionUIState>()((set, get) => ({
|
||||
// Set the directory together with the session id so chat hooks read the
|
||||
// same child store that send/SSE events will update during startup races.
|
||||
set({ currentSessionId: id, currentSessionDirectory: id ? resolvedDir ?? null : null })
|
||||
writeRuntimeSessionMemory(key, { sessionId: id, directory: resolvedDir ?? null })
|
||||
guessedSelectionSessionId = isGuessedDir && id ? id : null
|
||||
const rememberedDir = isGuessedDir ? null : resolvedDir ?? null
|
||||
writeRuntimeSessionMemory(key, { sessionId: id, directory: rememberedDir })
|
||||
// Keep the last NON-null session per runtime across app restarts (cold
|
||||
// mobile launches reopen it after the instance reconnects). Going back to
|
||||
// a draft intentionally does not erase it.
|
||||
if (id) {
|
||||
persistLastActiveSession(key, { sessionId: id, directory: resolvedDir ?? null })
|
||||
persistLastActiveSession(key, { sessionId: id, directory: rememberedDir })
|
||||
}
|
||||
|
||||
// Kick off the message fetch on the same tick, before React commits the
|
||||
@@ -1560,16 +1664,19 @@ export const useSessionUIStore = create<SessionUIState>()((set, get) => ({
|
||||
},
|
||||
|
||||
getDirectoryForSession: (sessionId) => {
|
||||
if (sessionId === get().currentSessionId && get().currentSessionDirectory) {
|
||||
return get().currentSessionDirectory
|
||||
}
|
||||
const resolved = resolveSessionDirectory(sessionId, (sid) => get().worktreeMetadata.get(sid))
|
||||
// The selection-time directory participates in resolution, it does not
|
||||
// short-circuit it. For a worktree session selected before its directory
|
||||
// store finished bootstrapping, that value is a startup fallback pointing
|
||||
// at the parent repository; letting it win would route every send, queue
|
||||
// key, and send-confirmation lookup to a directory that does not own the
|
||||
// session.
|
||||
const selected = sessionId === get().currentSessionId ? get().currentSessionDirectory : null
|
||||
const resolved = resolveSessionDirectory(
|
||||
sessionId,
|
||||
(sid) => get().worktreeMetadata.get(sid),
|
||||
selected,
|
||||
)
|
||||
if (resolved) return resolved
|
||||
const attachmentDirectory = getAttachedSessionDirectory(getAttachmentForSession(sessionId))
|
||||
if (attachmentDirectory) return attachmentDirectory
|
||||
const sessions = getAllSyncSessions()
|
||||
const session = sessions.find((s) => s.id === sessionId)
|
||||
if (session) return resolveDirectoryKey(session)
|
||||
const globalStore = useGlobalSessionsStore.getState()
|
||||
const globalSession = [...globalStore.activeSessions, ...globalStore.archivedSessions]
|
||||
.find((s) => s.id === sessionId)
|
||||
@@ -1634,6 +1741,11 @@ export const useSessionUIStore = create<SessionUIState>()((set, get) => ({
|
||||
|
||||
setSessionDirectory: (sessionId, directory) => {
|
||||
const normalized = normalizePath(directory)
|
||||
// Callers set this from a confirmed destination (a completed move, a
|
||||
// created worktree), so the selection is no longer a guess.
|
||||
if (sessionId === guessedSelectionSessionId) {
|
||||
guessedSelectionSessionId = null
|
||||
}
|
||||
if (sessionId === get().currentSessionId) {
|
||||
set({ currentSessionDirectory: normalized })
|
||||
writeRuntimeSessionMemory(runtimeMemoryKey(), { sessionId, directory: normalized })
|
||||
|
||||
Reference in New Issue
Block a user