Files
openchamber/packages/ui/src/sync/session-directory-resolution.ts
T
Bohdan Triapitsyn 2c52240f8e 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.
2026-08-03 12:51:12 +03:00

138 lines
5.1 KiB
TypeScript

/**
* Session → directory resolution precedence.
*
* A session's directory decides which OpenCode project every send, message
* fetch, queue key, and confirmation lookup is routed to. Getting it wrong is
* not a cosmetic problem: the prompt is posted against a directory that does
* not own the session, the send is rejected, and the optimistic message is
* rolled back with no visible error.
*
* The precedence below is deliberate and ordered by authority, not by
* convenience:
*
* The ordering discriminator is **whether the server confirmed the path**, not
* whether the value is local or synced:
*
* 1. `authoritative` — the child store that actually holds the session, then
* the session's own record. Server-backed truth for an indexed session.
* 2. `selected` — the directory captured when the session was selected, but
* only when it came from a server response (the directory `createSession`
* returned, which may be a canonicalized form of what was requested). A
* selection that fell back to the active directory is a guess and is not
* passed here at all.
* 3. `attachment` / `worktreeMetadata` — the worktree this client assigned to
* the session. Both hold the *requested* path, before the server had a
* chance to canonicalize it, so they are a hint for a session sync has not
* indexed yet, never a correction of a confirmed one.
* 4. `remembered` — the per-runtime directory persisted across restarts. Last
* resort: it survives reloads, so a value written from a startup fallback
* would otherwise outlive the race that produced it.
*
* Routing a prompt by an unconfirmed path posts it against a directory that
* does not own the session, and the send is rejected. Moves need no exception:
* a session move updates the owning child store before any client-side value.
*/
export type SessionDirectorySource =
| 'authoritative'
| 'selected'
| 'attachment'
| 'worktree-metadata'
| 'remembered'
| 'none'
export type SessionDirectorySources = {
/** Directory of the child store that holds the session, or its own record. */
authoritative?: string | null
/** Server-confirmed directory captured at selection. Never a guessed one. */
selected?: string | null
/** Worktree attachment recorded for this session; the requested path. */
attachment?: string | null
/** Worktree metadata captured when the session was created in a worktree. */
worktreeMetadata?: string | null
/** Directory persisted for this runtime; may outlive the race that wrote it. */
remembered?: string | null
}
export type SessionDirectoryResolution = {
directory: string | null
source: SessionDirectorySource
/**
* Set when a lower-priority source disagrees with the winning one. This is
* the signature of the stale-directory bug: a persisted or selection-time
* fallback pointing at the parent repository while the session lives in a
* worktree.
*/
conflict: { source: SessionDirectorySource; directory: string } | null
}
const RESOLUTION_ORDER: ReadonlyArray<Exclude<SessionDirectorySource, 'none'>> = [
'authoritative',
'selected',
'attachment',
'worktree-metadata',
'remembered',
]
const readSource = (
sources: SessionDirectorySources,
source: Exclude<SessionDirectorySource, 'none'>,
): string | null => {
const value = source === 'attachment'
? sources.attachment
: source === 'worktree-metadata'
? sources.worktreeMetadata
: source === 'authoritative'
? sources.authoritative
: source === 'selected'
? sources.selected
: sources.remembered
if (typeof value !== 'string') return null
const trimmed = value.trim()
return trimmed.length > 0 ? trimmed : null
}
/**
* Resolve a session directory from every known source, reporting which source
* won and whether a weaker source disagreed.
*
* Callers normalize paths before passing them in; this module only orders
* authority and never rewrites a path.
*/
export const resolveSessionDirectoryFromSources = (
sources: SessionDirectorySources,
): SessionDirectoryResolution => {
let winner: { source: SessionDirectorySource; directory: string } | null = null
let conflict: { source: SessionDirectorySource; directory: string } | null = null
for (const source of RESOLUTION_ORDER) {
const directory = readSource(sources, source)
if (!directory) continue
if (!winner) {
winner = { source, directory }
continue
}
if (!conflict && directory !== winner.directory) {
conflict = { source, directory }
}
}
if (!winner) {
return { directory: null, source: 'none', conflict: null }
}
return { directory: winner.directory, source: winner.source, conflict }
}
/** Every source that carries a value, in precedence order. For diagnostics. */
export const describeSessionDirectorySources = (
sources: SessionDirectorySources,
): Array<{ source: SessionDirectorySource; directory: string }> => {
const described: Array<{ source: SessionDirectorySource; directory: string }> = []
for (const source of RESOLUTION_ORDER) {
const directory = readSource(sources, source)
if (directory) described.push({ source, directory })
}
return described
}