fix(worktrees): protect user changes across ambiguous move failures
Post-merge hardening of the session-to-worktree move (#2998), driven by review findings on the follow-up pass: - an ambiguous transport failure (relay abort, timeout) on the change-carrying move no longer force-deletes the fresh worktree that may hold the user's only copy of their changes; both intent kinds surface honest guidance and refresh both directories - assertSdkSuccess re-tags ambiguous transport errors when wrapping SDK failures, so ambiguity classification survives the wrapper on every path, matching the prompt-send precedent - session liveness checks scan all child stores plus the global status index, and report unknown (not idle) when no store covers the session — an evicted background directory can no longer make a busy session look movable - incomplete-rollback errors carry the changes-may-be-in-destination guidance instead of swallowing it - move-message assembly shared across the three call sites; tests now exercise the real ambiguity classifier (extracted to send-failure-classification.ts) instead of a hand-mirrored mock - i18n fallout from the merge train: Turkish gains the 21 worktree-move keys, all 12 locales get the hedged ambiguous-failure toast; owning DOCUMENTATION.md files record the new contracts
This commit is contained in:
@@ -268,7 +268,8 @@ Rules:
|
||||
6. After session creation, the directory returned by the server is authoritative over the requested draft directory. The server may canonicalize a worktree path, and the first prompt must use the same directory identity as the created session.
|
||||
7. Regular new-chat drafts that inherit the persisted current/last directory must not create a session against a confirmed-missing path. Fall back to the active project only when OpenCode reports the directory missing; keep explicit worktree targets, in-flight worktree creation, and unknown/offline probes unchanged, and do not persist the fallback until session creation succeeds. A concurrent draft rewrite to that same active-project fallback must not abort session creation.
|
||||
8. A prompt send that fails **after** the request left the client is ambiguous, never a definite failure: the server may already be answering it. Transports tag those errors (`markAmbiguousTransportFailure` in `@/lib/relay/transport-error`; the relay tunnel tags every stream that dies with a request in flight), and `isAmbiguousSendFailure` reads the tag before falling back to status/text heuristics. An ambiguous failure waits for the connection to return, refetches recent messages, and confirms the optimistic message in place instead of rolling it back — rolling it back lets the message queue re-send a prompt the engine is already running, producing two independent AI responses for one user message.
|
||||
9. Revert and unrevert cascade through known descendant sessions before mutating the parent. Revert uses the first descendant user message at or after the parent's target timestamp, including equal timestamps because message IDs do not define chronology. A descendant failure is logged and does not block its siblings or the parent. The parent runs last so its shared-directory file snapshot remains authoritative. A busy descendant is aborted before it is reverted, like the parent, so nothing keeps writing past the revert boundary. Redo clears the revert marker on every descendant, including markers the user set on a subagent independently of the parent undo.
|
||||
9. `SessionLiveActivity` has three answers and `unknown` is never `idle`. `getSessionLiveActivity` reports `active` when any child store or the global session-status index holds a non-idle status, `idle` only when a child store actually covers the session's directory, and `unknown` otherwise — child stores are evicted for background directories, and the global index keeps only non-idle entries, so absence of a status is not proof of idleness. Callers that gate a destructive action (worktree moves) must refuse on `unknown`.
|
||||
10. Revert and unrevert cascade through known descendant sessions before mutating the parent. Revert uses the first descendant user message at or after the parent's target timestamp, including equal timestamps because message IDs do not define chronology. A descendant failure is logged and does not block its siblings or the parent. The parent runs last so its shared-directory file snapshot remains authoritative. A busy descendant is aborted before it is reverted, like the parent, so nothing keeps writing past the revert boundary. Redo clears the revert marker on every descendant, including markers the user set on a subagent independently of the parent undo.
|
||||
|
||||
Examples of global-store updates performed in `session-actions.ts`:
|
||||
|
||||
|
||||
@@ -0,0 +1,49 @@
|
||||
/**
|
||||
* Send-failure classification.
|
||||
*
|
||||
* Pure predicates over an unknown error value: no store, SDK, or transport
|
||||
* imports. They live outside `session-actions` so callers (and their tests) can
|
||||
* use the real classifier instead of re-implementing a partial mirror of it.
|
||||
*/
|
||||
|
||||
import { isAmbiguousTransportFailure } from "@/lib/relay/transport-error"
|
||||
|
||||
export function getErrorStatus(error: unknown): number | null {
|
||||
if (!error || typeof error !== "object") return null
|
||||
// SAFETY: `error` is a non-null object here; both probes read optional
|
||||
// properties an SDK/fetch rejection may carry and validate them below.
|
||||
const direct = (error as { status?: unknown }).status
|
||||
if (typeof direct === "number") return direct
|
||||
// SAFETY: same non-null object, optional property probe validated below.
|
||||
const response = (error as { response?: { status?: unknown } }).response
|
||||
return typeof response?.status === "number" ? response.status : null
|
||||
}
|
||||
|
||||
export function isAmbiguousSendFailure(error: unknown): boolean {
|
||||
// Authoritative first: the transport that lost the request says whether it
|
||||
// had already been dispatched. The text matching below only covers direct
|
||||
// fetch/HTTP failures, whose wording we do not control either — relay tunnel
|
||||
// aborts ("stream aborted by host", "relay keepalive timeout", …) match none
|
||||
// of those patterns and used to be misread as definite failures.
|
||||
if (isAmbiguousTransportFailure(error)) return true
|
||||
|
||||
const status = getErrorStatus(error)
|
||||
if (status === 503 || status === 504 || status === 408) return true
|
||||
if (error instanceof TypeError) return true
|
||||
if (error instanceof DOMException && (error.name === "AbortError" || error.name === "TimeoutError")) return true
|
||||
|
||||
const message = error instanceof Error
|
||||
? error.message.toLowerCase()
|
||||
: typeof error === "string"
|
||||
? error.toLowerCase()
|
||||
: ""
|
||||
|
||||
return message.includes("timeout")
|
||||
|| message.includes("timed out")
|
||||
|| message.includes("failed to fetch")
|
||||
|| message.includes("networkerror")
|
||||
|| message.includes("network error")
|
||||
|| message.includes("gateway timeout")
|
||||
|| message.includes("econnreset")
|
||||
|| message.includes("socket hang up")
|
||||
}
|
||||
@@ -13,6 +13,7 @@ import { opencodeClient } from "@/lib/opencode/client"
|
||||
import { mergeSessionDirectoryMetadata, resolveGlobalSessionDirectory, useGlobalSessionsStore } from "@/stores/useGlobalSessionsStore"
|
||||
import { useConfigStore } from "@/stores/useConfigStore"
|
||||
import { registerSessionDirectory } from "./sync-refs"
|
||||
import { useGlobalSessionStatusStore } from "./global-session-status"
|
||||
import { recordSendFailure } from "./send-failure-log"
|
||||
import { isSyntheticPart } from "@/lib/messages/synthetic"
|
||||
import { materializeSessionSnapshots } from "./materialization"
|
||||
@@ -31,7 +32,8 @@ import { withLinkedIssue, type LinkedIssue } from "@/lib/linkedIssues"
|
||||
import { getImperativeSessionMessageLoader } from "./session-message-loader"
|
||||
import { cleanupPersistedSessionState } from "./session-deletion-cleanup"
|
||||
import { getRuntimeKey } from "@/lib/runtime-switch"
|
||||
import { isAmbiguousTransportFailure } from "@/lib/relay/transport-error"
|
||||
import { markAmbiguousTransportFailure } from "@/lib/relay/transport-error"
|
||||
import { getErrorStatus, isAmbiguousSendFailure } from "./send-failure-classification"
|
||||
import { getStaleRunningToolMessageID } from "./materialization"
|
||||
import { normalizePath } from "@/lib/pathNormalization"
|
||||
import { mergeMessages } from "./optimistic"
|
||||
@@ -135,7 +137,11 @@ function assertSdkSuccess<T>(result: SdkResult<T>, operation: string): T | undef
|
||||
const status = result.response?.status
|
||||
const error = new Error(`${operation} failed${status ? ` (${status})` : ""}: ${formatSdkError(result.error)}`) as Error & { status?: number }
|
||||
if (status !== undefined) error.status = status
|
||||
throw error
|
||||
// Wrapping loses the original error's identity: the transport's
|
||||
// "dispatched, outcome unknown" tag, a DOMException abort, a TypeError from
|
||||
// fetch. Re-tag the wrapper so `isAmbiguousSendFailure` still classifies it
|
||||
// as ambiguous instead of reading it as a definite server rejection.
|
||||
throw isAmbiguousSendFailure(result.error) ? markAmbiguousTransportFailure(error) : error
|
||||
}
|
||||
|
||||
function assertSdkData<T>(result: SdkResult<T>, operation: string): T {
|
||||
@@ -374,43 +380,6 @@ function connectionLostError(): Error {
|
||||
return new Error(`Connection lost${suffix}. Please wait for reconnection.`)
|
||||
}
|
||||
|
||||
function getErrorStatus(error: unknown): number | null {
|
||||
if (!error || typeof error !== "object") return null
|
||||
const direct = (error as { status?: unknown }).status
|
||||
if (typeof direct === "number") return direct
|
||||
const response = (error as { response?: { status?: unknown } }).response
|
||||
return typeof response?.status === "number" ? response.status : null
|
||||
}
|
||||
|
||||
function isAmbiguousSendFailure(error: unknown): boolean {
|
||||
// Authoritative first: the transport that lost the request says whether it
|
||||
// had already been dispatched. The text matching below only covers direct
|
||||
// fetch/HTTP failures, whose wording we do not control either — relay tunnel
|
||||
// aborts ("stream aborted by host", "relay keepalive timeout", …) match none
|
||||
// of those patterns and used to be misread as definite failures.
|
||||
if (isAmbiguousTransportFailure(error)) return true
|
||||
|
||||
const status = getErrorStatus(error)
|
||||
if (status === 503 || status === 504 || status === 408) return true
|
||||
if (error instanceof TypeError) return true
|
||||
if (error instanceof DOMException && (error.name === "AbortError" || error.name === "TimeoutError")) return true
|
||||
|
||||
const message = error instanceof Error
|
||||
? error.message.toLowerCase()
|
||||
: typeof error === "string"
|
||||
? error.toLowerCase()
|
||||
: ""
|
||||
|
||||
return message.includes("timeout")
|
||||
|| message.includes("timed out")
|
||||
|| message.includes("failed to fetch")
|
||||
|| message.includes("networkerror")
|
||||
|| message.includes("network error")
|
||||
|| message.includes("gateway timeout")
|
||||
|| message.includes("econnreset")
|
||||
|| message.includes("socket hang up")
|
||||
}
|
||||
|
||||
// Wait briefly for the pipeline to re-establish connection before failing a
|
||||
// send. Transient reconnects (heartbeat race, WS→SSE fallback, brief network
|
||||
// blip) otherwise surface as a hard "Connection lost" toast even though the
|
||||
@@ -443,21 +412,54 @@ type DescendantSession = {
|
||||
directory: string
|
||||
}
|
||||
|
||||
/** "unknown" means no live source covers this session right now, so no caller
|
||||
* may treat it as idle on this answer. "idle" requires positive coverage. */
|
||||
export type SessionLiveActivity = "unknown" | "idle" | "active"
|
||||
|
||||
/**
|
||||
* A session's live status can live in a different child store than the one that
|
||||
* wins the directory dedup, so any store reporting a non-idle status counts.
|
||||
* Read at the moment of use: a descendant can start working after the subtree
|
||||
* snapshot was taken.
|
||||
*
|
||||
* Absence of a non-idle status is not proof of idleness. Child stores are
|
||||
* evicted for background directories, and the global status index keeps only
|
||||
* non-idle entries, so "no report" and "idle" are different answers: report
|
||||
* "idle" only when a child store actually covers the session's directory.
|
||||
*/
|
||||
function isSessionBusyNow(sessionId: string): boolean {
|
||||
export function getSessionLiveActivity(sessionId: string): SessionLiveActivity {
|
||||
const stores = _childStores
|
||||
if (!stores) return false
|
||||
|
||||
for (const [, store] of stores.children) {
|
||||
const status = store.getState().session_status?.[sessionId]
|
||||
if (status && status.type !== "idle") return true
|
||||
if (stores) {
|
||||
for (const [, store] of stores.children) {
|
||||
const status = store.getState().session_status?.[sessionId]
|
||||
if (status && status.type !== "idle") return "active"
|
||||
}
|
||||
}
|
||||
return false
|
||||
|
||||
// Cross-directory live index: populated by global events and authoritative
|
||||
// per-directory status snapshots, and it survives child-store eviction.
|
||||
if (useGlobalSessionStatusStore.getState().statusById.has(sessionId)) return "active"
|
||||
|
||||
if (!stores) return "unknown"
|
||||
return isSessionCoveredByChildStore(sessionId, stores) ? "idle" : "unknown"
|
||||
}
|
||||
|
||||
function isSessionCoveredByChildStore(sessionId: string, stores: ChildStoreManager): boolean {
|
||||
if (findSessionDirectoryInChildStores(sessionId)) return true
|
||||
const directory = useSessionUIStore.getState().getDirectoryForSession(sessionId)
|
||||
?? resolveKnownSessionDirectory(sessionId)
|
||||
if (!directory) return false
|
||||
return stores.children.has(normalizePath(directory) ?? directory)
|
||||
}
|
||||
|
||||
function resolveKnownSessionDirectory(sessionId: string): string | null {
|
||||
const globalSession = getGlobalSessionSnapshot(sessionId)
|
||||
return globalSession ? resolveGlobalSessionDirectory(globalSession) : null
|
||||
}
|
||||
|
||||
export function isSessionBusyNow(sessionId: string): boolean {
|
||||
return getSessionLiveActivity(sessionId) === "active"
|
||||
}
|
||||
|
||||
async function abortDescendantIfBusy(sessionId: string, directory: string): Promise<void> {
|
||||
|
||||
Reference in New Issue
Block a user