From 7a9cbbf71734d26f36523eae237e5f5eef1bfd7c Mon Sep 17 00:00:00 2001 From: Julius Marminge <51714798+juliusmarminge@users.noreply.github.com> Date: Sun, 25 Oct 2026 23:46:14 -0700 Subject: [PATCH 1/2] refactor(client): composers share one workspace snapshot refresh per provider and cwd Web and mobile composers each kept their own copy of the provider workspace snapshot refresh: refs for the last request and failure count, state for the retry cooldown, a timer effect, a snapshot-loss effect and a trigger effect. Two composers looking at the same environment, provider and cwd scanned separately. The refresh now lives in one client-runtime atom family keyed by environment, provider instance and cwd. Reading the atom scans when the snapshot is missing or stale; refreshing it re-checks (the composer does this as the user types). It owns the retry timer through deadlineStore, so the timer dies with the last subscriber, and it keeps main's rules: a 5 minute TTL per request, a 10 s cooldown after a failed scan, a timer retry only while the server reports slash commands pending, at most three failed scans in a row, and an immediate rescan when the server drops the snapshot. Effects: web useProviderWorkspaceSnapshotRefresh 2 -> 0; mobile use-composer-command-menu 5 -> 2 (the two caret-selection effects stay, they are a separate change). Co-Authored-By: Claude Opus 5.5 --- .../threads/use-composer-command-menu.test.ts | 9 +- .../threads/use-composer-command-menu.ts | 139 +++--------- apps/web/src/components/chat/ChatComposer.tsx | 3 +- .../useProviderWorkspaceSnapshotRefresh.ts | 158 ++------------ .../client-runtime/src/providerSkills.test.ts | 176 ++++++++++++++- packages/client-runtime/src/providerSkills.ts | 200 +++++++++++++++++- 6 files changed, 425 insertions(+), 260 deletions(-) diff --git a/apps/mobile/src/features/threads/use-composer-command-menu.test.ts b/apps/mobile/src/features/threads/use-composer-command-menu.test.ts index f038fe8f9c25f..1b025a38418c2 100644 --- a/apps/mobile/src/features/threads/use-composer-command-menu.test.ts +++ b/apps/mobile/src/features/threads/use-composer-command-menu.test.ts @@ -1,7 +1,6 @@ import { describe, expect, it, vi } from "vite-plus/test"; import { ProviderDriverKind } from "@t3tools/contracts"; -const refreshProviders = vi.hoisted(() => vi.fn()); vi.mock("react-native", () => ({ Alert: { alert: vi.fn() } })); vi.mock("../../state/queries", () => ({ @@ -15,10 +14,10 @@ vi.mock("../../state/use-composer-drafts", () => ({ })); vi.mock("../../lib/uuid", () => ({ uuidv4: () => "context-id" })); vi.mock("../../state/server", () => ({ - serverEnvironment: { refreshProviders: Symbol("refreshProviders") }, -})); -vi.mock("../../state/use-atom-command", () => ({ - useAtomCommand: () => refreshProviders, + serverEnvironment: { + providersValueAtom: vi.fn(), + refreshProviders: Symbol("refreshProviders"), + }, })); import { diff --git a/apps/mobile/src/features/threads/use-composer-command-menu.ts b/apps/mobile/src/features/threads/use-composer-command-menu.ts index a1d3c42c7fc7a..6c687e03afeea 100644 --- a/apps/mobile/src/features/threads/use-composer-command-menu.ts +++ b/apps/mobile/src/features/threads/use-composer-command-menu.ts @@ -11,10 +11,7 @@ import { matchComposerThreadItems } from "@t3tools/client-runtime/composerThread import type { EnvironmentThreadShell } from "@t3tools/client-runtime/state/models"; const EMPTY_THREAD_SHELLS: ReadonlyArray = []; -import { - COMPOSER_CONTEXT_MAX_RECORDS, - PROVIDER_WORKSPACE_SNAPSHOT_TTL_MS, -} from "@t3tools/contracts"; +import { COMPOSER_CONTEXT_MAX_RECORDS } from "@t3tools/contracts"; import { formatComposerContextReference } from "@t3tools/shared/composerContextReferences"; import { pullRequestComposerContext, threadComposerContext } from "../../lib/composerContext"; import { uuidv4 } from "../../lib/uuid"; @@ -36,20 +33,20 @@ import { scoreQueryMatch, } from "@t3tools/shared/searchRanking"; import { + createProviderWorkspaceSnapshotRefreshAtomFamily, dedupeProviderSkillsByName, getProviderSkillsForSlashMenu, getProviderSlashCommandsForSlashMenu, isProviderSkillUserInvocable, - hasCompleteProviderWorkspaceSnapshot, - hasCurrentProviderWorkspaceSnapshot, resolveProviderSkillsForCwd, resolveProviderSlashCommandsForCwd, } from "@t3tools/client-runtime/providerSkills"; +import { useAtomRefresh } from "@effect/atom-react"; +import { Atom } from "effect/reactivity"; import { useEffect, useRef, useState } from "react"; import type { ComposerEditorSelection } from "../../components/ComposerEditor"; import { serverEnvironment } from "../../state/server"; -import { useAtomCommand } from "../../state/use-atom-command"; import { useComposerPathSearch, useComposerPullRequestSearch, @@ -58,9 +55,14 @@ import { import type { ComposerCommandItem } from "./ComposerCommandPopover"; import { matchesSlashSkillQuery } from "./composerSlashSkillSearch"; -const WORKSPACE_SNAPSHOT_RETRY_COOLDOWN_MS = 10_000; -// A cwd that never scans, such as a deleted worktree, would otherwise retry forever. -const WORKSPACE_SNAPSHOT_MAX_ATTEMPTS = 3; +const providerWorkspaceSnapshotRefresh = createProviderWorkspaceSnapshotRefreshAtomFamily({ + providers: serverEnvironment.providersValueAtom, + refreshProviders: serverEnvironment.refreshProviders, +}); + +const idleWorkspaceSnapshotRefreshAtom: Atom.Atom = Atom.make(undefined).pipe( + Atom.withLabel("mobile:provider-workspace-snapshot-refresh:idle"), +); function composerSelectionAtEnd(draftMessage: string): ComposerEditorSelection { return { start: draftMessage.length, end: draftMessage.length }; @@ -214,9 +216,6 @@ export function useComposerCommandMenu({ // Thread mentions rank by recency as of when the composer mounted. const [now] = useState(() => Date.now()); const previousOwnerKeyRef = useRef(ownerKey); - const onSelectionChange = (nextSelection: ComposerEditorSelection) => { - setSelection(nextSelection); - }; useEffect(() => { const inserted = ownerKey ? readComposerDraftSelection(ownerKey, draftMessage) : null; if (inserted) { @@ -245,109 +244,21 @@ export function useComposerCommandMenu({ const skills = selectedProviderStatus ? resolveProviderSkillsForCwd(selectedProviderStatus, projectCwd) : []; - const refreshProviders = useAtomCommand(serverEnvironment.refreshProviders, { - reportFailure: false, - }); const selectedProviderInstanceId = selectedProviderStatus?.instanceId; - const hasWorkspaceSnapshot = hasCompleteProviderWorkspaceSnapshot( - selectedProviderStatus, - projectCwd, - ); - // Record client request time to enforce TTL even when the client clock runs ahead of the server. - const workspaceRefreshKeyRef = useRef<{ key: string; requestedAt: number } | null>(null); - const failedRefreshesRef = useRef<{ key: string; count: number } | null>(null); - const [workspaceRefreshRetry, setWorkspaceRefreshRetry] = useState<{ - key: string; - notBefore: number; - } | null>(null); - const workspaceRefreshScopeKey = + const refreshWorkspaceSnapshot = useAtomRefresh( environmentId && projectCwd && selectedProviderInstanceId - ? `${environmentId}:${selectedProviderInstanceId}:${projectCwd}` - : null; - const workspaceSlashCommandsPending = - selectedProviderStatus?.workspaceSnapshots?.some( - (snapshot) => snapshot.cwd === projectCwd && snapshot.slashCommandsPending === true, - ) ?? false; - useEffect(() => { - if ( - !workspaceSlashCommandsPending || - !workspaceRefreshRetry || - workspaceRefreshRetry.key !== workspaceRefreshScopeKey - ) - return; - const timeout = setTimeout( - () => { - setWorkspaceRefreshRetry((current) => (current === workspaceRefreshRetry ? null : current)); - }, - Math.max(0, workspaceRefreshRetry.notBefore - Date.now()), - ); - return () => clearTimeout(timeout); - }, [workspaceRefreshRetry, workspaceRefreshScopeKey, workspaceSlashCommandsPending]); - const hadWorkspaceSnapshotRef = useRef(false); - useEffect(() => { - if (hadWorkspaceSnapshotRef.current && !hasWorkspaceSnapshot) { - workspaceRefreshKeyRef.current = null; - setWorkspaceRefreshRetry(null); - } - hadWorkspaceSnapshotRef.current = hasWorkspaceSnapshot; - }, [hasWorkspaceSnapshot]); - useEffect(() => { - if (!environmentId || !projectCwd || !selectedProviderInstanceId) return; - const key = `${environmentId}:${selectedProviderInstanceId}:${projectCwd}`; - const now = Date.now(); - const lastRequest = workspaceRefreshKeyRef.current; - if ( - lastRequest?.key === key && - now - lastRequest.requestedAt < PROVIDER_WORKSPACE_SNAPSHOT_TTL_MS - ) - return; - if (hasCurrentProviderWorkspaceSnapshot(selectedProviderStatus, projectCwd, now)) { - // oxlint-disable-next-line react/set-state-in-effect -- A current provider workspace snapshot clears the pending refresh retry. - setWorkspaceRefreshRetry(null); - return; - } - const retry = workspaceRefreshRetry; - if (retry?.key === key && now < retry.notBefore) return; - const request = { key, requestedAt: now }; - workspaceRefreshKeyRef.current = request; - const retryLater = () => { - if (workspaceRefreshKeyRef.current !== request) return; - const failed = failedRefreshesRef.current; - const count = failed?.key === key ? failed.count + 1 : 1; - failedRefreshesRef.current = { key, count }; - // Keeping the request marker lets the TTL hold off the next scan. - if (count >= WORKSPACE_SNAPSHOT_MAX_ATTEMPTS) return; - workspaceRefreshKeyRef.current = null; - setWorkspaceRefreshRetry({ - key, - notBefore: Date.now() + WORKSPACE_SNAPSHOT_RETRY_COOLDOWN_MS, - }); - }; - void refreshProviders({ - environmentId, - input: { instanceId: selectedProviderInstanceId, cwd: projectCwd }, - }).then((result) => { - const refreshed = - result._tag === "Success" && - hasCompleteProviderWorkspaceSnapshot( - result.value.providers.find( - (provider) => provider.instanceId === selectedProviderInstanceId, - ), - projectCwd, - ); - if (!refreshed) retryLater(); - else failedRefreshesRef.current = null; - }, retryLater); - }, [ - // oxlint-disable-next-line react/exhaustive-effect-dependencies -- Typing in the draft re-checks whether the provider workspace snapshot needs refreshing. - draftMessage, - environmentId, - projectCwd, - refreshProviders, - selectedProviderInstanceId, - selectedProviderStatus, - workspaceRefreshRetry, - ]); + ? providerWorkspaceSnapshotRefresh({ + environmentId, + instanceId: selectedProviderInstanceId, + cwd: projectCwd, + }) + : idleWorkspaceSnapshotRefreshAtom, + ); + // Selection changes on every keystroke, which is when a stale snapshot is rechecked. + const onSelectionChange = (nextSelection: ComposerEditorSelection) => { + setSelection(nextSelection); + refreshWorkspaceSnapshot(); + }; const trigger = (() => { if (!enabled || selection.start !== selection.end) { diff --git a/apps/web/src/components/chat/ChatComposer.tsx b/apps/web/src/components/chat/ChatComposer.tsx index ce4b9258fd625..f2dc2f2f84224 100644 --- a/apps/web/src/components/chat/ChatComposer.tsx +++ b/apps/web/src/components/chat/ChatComposer.tsx @@ -1006,8 +1006,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) const refreshWorkspaceSnapshotOnTyping = useProviderWorkspaceSnapshotRefresh({ environmentId, gitCwd, - selectedProviderEntry, - selectedProviderStatus: selectedProviderStatus, + instanceId: selectedProviderEntry?.instanceId, }); const selectedPromptEffort = composerProviderState.promptEffort; diff --git a/apps/web/src/components/chat/useProviderWorkspaceSnapshotRefresh.ts b/apps/web/src/components/chat/useProviderWorkspaceSnapshotRefresh.ts index 7256052cebbbc..8d56ed11c64a2 100644 --- a/apps/web/src/components/chat/useProviderWorkspaceSnapshotRefresh.ts +++ b/apps/web/src/components/chat/useProviderWorkspaceSnapshotRefresh.ts @@ -1,146 +1,32 @@ -import { - type EnvironmentId, - PROVIDER_WORKSPACE_SNAPSHOT_TTL_MS, - type ServerProvider, -} from "@t3tools/contracts"; -import { type ProviderInstanceEntry } from "../../providerInstances"; -import { - hasCompleteProviderWorkspaceSnapshot, - hasCurrentProviderWorkspaceSnapshot, -} from "@t3tools/client-runtime/providerSkills"; +import { useAtomRefresh } from "@effect/atom-react"; +import { type EnvironmentId, type ProviderInstanceId } from "@t3tools/contracts"; +import { createProviderWorkspaceSnapshotRefreshAtomFamily } from "@t3tools/client-runtime/providerSkills"; +import { Atom } from "effect/reactivity"; + import { serverEnvironment } from "../../state/server"; -import { useAtomCommand } from "../../state/use-atom-command"; -import { useEffect, useEffectEvent, useRef, useState } from "react"; -import { useStableCallback } from "../../hooks/useStableCallback"; -const WORKSPACE_SNAPSHOT_RETRY_COOLDOWN_MS = 10_000; -// A cwd that never scans, such as a deleted worktree, would otherwise retry forever. -const WORKSPACE_SNAPSHOT_MAX_ATTEMPTS = 3; +const providerWorkspaceSnapshotRefresh = createProviderWorkspaceSnapshotRefreshAtomFamily({ + providers: serverEnvironment.providersValueAtom, + refreshProviders: serverEnvironment.refreshProviders, +}); + +const idleRefreshAtom: Atom.Atom = Atom.make(undefined).pipe( + Atom.withLabel("provider-workspace-snapshot-refresh:idle"), +); /** * Keeps the selected provider's workspace snapshot fresh for the composer's - * cwd. Returns a check to run when the user types. + * cwd while mounted. Returns a check to run when the user types. */ export function useProviderWorkspaceSnapshotRefresh(input: { readonly environmentId: EnvironmentId; readonly gitCwd: string | null; - readonly selectedProviderEntry: ProviderInstanceEntry | undefined; - readonly selectedProviderStatus: ServerProvider | null; -}) { - const { environmentId, gitCwd, selectedProviderEntry, selectedProviderStatus } = input; - const refreshProviders = useAtomCommand(serverEnvironment.refreshProviders, { - reportFailure: false, - }); - // The last scan this composer asked for. A request inside the TTL is not - // repeated, so a client clock ahead of the server's cannot loop rescans. - const workspaceRefreshKeyRef = useRef<{ key: string; requestedAt: number } | null>(null); - const failedRefreshesRef = useRef<{ key: string; count: number } | null>(null); - const [workspaceRefreshRetry, setWorkspaceRefreshRetry] = useState<{ - key: string; - notBefore: number; - } | null>(null); - const workspaceRefreshScopeKey = - gitCwd && selectedProviderEntry - ? `${environmentId}:${selectedProviderEntry.instanceId}:${gitCwd}` - : null; - const workspaceSlashCommandsPending = - selectedProviderStatus?.workspaceSnapshots?.some( - (snapshot) => snapshot.cwd === gitCwd && snapshot.slashCommandsPending === true, - ) ?? false; - useEffect(() => { - if ( - !workspaceSlashCommandsPending || - !workspaceRefreshRetry || - workspaceRefreshRetry.key !== workspaceRefreshScopeKey - ) - return; - const timeout = setTimeout( - () => { - setWorkspaceRefreshRetry((current) => (current === workspaceRefreshRetry ? null : current)); - }, - Math.max(0, workspaceRefreshRetry.notBefore - Date.now()), - ); - return () => clearTimeout(timeout); - }, [workspaceRefreshRetry, workspaceRefreshScopeKey, workspaceSlashCommandsPending]); - // Losing a snapshot (the server dropped it) lifts both the TTL and the retry - // cooldown so the next check rescans at once. - const hasWorkspaceSnapshot = hasCompleteProviderWorkspaceSnapshot(selectedProviderStatus, gitCwd); - const [hadWorkspaceSnapshot, setHadWorkspaceSnapshot] = useState(hasWorkspaceSnapshot); - const [workspaceSnapshotLossCount, setWorkspaceSnapshotLossCount] = useState(0); - if (hadWorkspaceSnapshot !== hasWorkspaceSnapshot) { - setHadWorkspaceSnapshot(hasWorkspaceSnapshot); - if (!hasWorkspaceSnapshot) { - setWorkspaceSnapshotLossCount((count) => count + 1); - setWorkspaceRefreshRetry(null); - } - } - const lastWorkspaceSnapshotLossRef = useRef(workspaceSnapshotLossCount); - const refreshWorkspaceSnapshotIfStale = ( - now: number, - key: string | null, - retry: { key: string; notBefore: number } | null, - snapshotLossCount: number, - ) => { - if (lastWorkspaceSnapshotLossRef.current !== snapshotLossCount) { - lastWorkspaceSnapshotLossRef.current = snapshotLossCount; - workspaceRefreshKeyRef.current = null; - } - if (key === null || !gitCwd || !selectedProviderEntry) return; - const lastRequest = workspaceRefreshKeyRef.current; - if ( - lastRequest?.key === key && - now - lastRequest.requestedAt < PROVIDER_WORKSPACE_SNAPSHOT_TTL_MS - ) - return; - // A pending retry needs no clearing here: its cooldown lapses long before the TTL. - if (hasCurrentProviderWorkspaceSnapshot(selectedProviderStatus, gitCwd, now)) return; - if (retry?.key === key && now < retry.notBefore) return; - const request = { key, requestedAt: now }; - workspaceRefreshKeyRef.current = request; - const retryLater = () => { - if (workspaceRefreshKeyRef.current !== request) return; - const failed = failedRefreshesRef.current; - const count = failed?.key === key ? failed.count + 1 : 1; - failedRefreshesRef.current = { key, count }; - // Keeping the request marker lets the TTL hold off the next scan. - if (count >= WORKSPACE_SNAPSHOT_MAX_ATTEMPTS) return; - workspaceRefreshKeyRef.current = null; - setWorkspaceRefreshRetry({ - key, - notBefore: Date.now() + WORKSPACE_SNAPSHOT_RETRY_COOLDOWN_MS, - }); - }; - void refreshProviders({ - environmentId, - input: { instanceId: selectedProviderEntry.instanceId, cwd: gitCwd }, - }).then((result) => { - const refreshedSnapshot = - result._tag === "Success" && - hasCompleteProviderWorkspaceSnapshot( - result.value.providers.find( - (provider) => provider.instanceId === selectedProviderEntry.instanceId, - ), - gitCwd, - ); - if (!refreshedSnapshot) retryLater(); - else failedRefreshesRef.current = null; - }, retryLater); - }; - const refreshWorkspaceSnapshotOnChange = useEffectEvent(refreshWorkspaceSnapshotIfStale); - useEffect(() => { - refreshWorkspaceSnapshotOnChange( - Date.now(), - workspaceRefreshScopeKey, - workspaceRefreshRetry, - workspaceSnapshotLossCount, - ); - }, [workspaceRefreshScopeKey, workspaceRefreshRetry, workspaceSnapshotLossCount]); - return useStableCallback(() => - refreshWorkspaceSnapshotIfStale( - Date.now(), - workspaceRefreshScopeKey, - workspaceRefreshRetry, - workspaceSnapshotLossCount, - ), - ); + readonly instanceId: ProviderInstanceId | undefined; +}): () => void { + const { environmentId, gitCwd, instanceId } = input; + const atom = + gitCwd && instanceId + ? providerWorkspaceSnapshotRefresh({ environmentId, instanceId, cwd: gitCwd }) + : idleRefreshAtom; + return useAtomRefresh(atom); } diff --git a/packages/client-runtime/src/providerSkills.test.ts b/packages/client-runtime/src/providerSkills.test.ts index a4ae4b5aefdd7..7539861026165 100644 --- a/packages/client-runtime/src/providerSkills.test.ts +++ b/packages/client-runtime/src/providerSkills.test.ts @@ -1,7 +1,15 @@ -import { ProviderDriverKind, ProviderInstanceId, type ServerProvider } from "@t3tools/contracts"; -import { describe, expect, it } from "vite-plus/test"; +import { + EnvironmentId, + ProviderDriverKind, + ProviderInstanceId, + type ServerProvider, +} from "@t3tools/contracts"; +import * as Cause from "effect/Cause"; +import { AsyncResult, Atom, AtomRegistry } from "effect/reactivity"; +import { afterEach, describe, expect, it, vi } from "vite-plus/test"; import { + createProviderWorkspaceSnapshotRefreshAtomFamily, getProviderSkillsForSlashMenu, hasCompleteProviderWorkspaceSnapshot, resolveProviderSkillsForCwd, @@ -123,3 +131,167 @@ describe("workspace provider snapshots", () => { expect(hasCompleteProviderWorkspaceSnapshot(provider, null)).toBe(false); }); }); + +describe("provider workspace snapshot refresh atom", () => { + afterEach(() => { + vi.useRealTimers(); + }); + + const cwd = "/workspace/project-a"; + const environmentId = EnvironmentId.make("env"); + const withSnapshot = ( + checkedAt: string, + slashCommandsPending?: boolean, + ): ReadonlyArray => [ + { + ...provider, + workspaceSnapshots: [ + { + ...provider.workspaceSnapshots[0]!, + checkedAt, + ...(slashCommandsPending ? { slashCommandsPending } : {}), + }, + ], + }, + ]; + const noSnapshot: ReadonlyArray = [{ ...provider, workspaceSnapshots: [] }]; + + type Response = () => + | ReadonlyArray + | null + | Promise | null>; + + function setup(initialProviders: ReadonlyArray, initialResponses?: Response[]) { + const providersAtom = Atom.make | null>(initialProviders); + const responses: Response[] = [...(initialResponses ?? [])]; + const run = vi.fn(async () => { + const providers = await (responses.shift() ?? (() => null))(); + return providers === null + ? AsyncResult.failure<{ providers: ReadonlyArray }, string>( + Cause.fail("offline"), + ) + : AsyncResult.success({ providers }); + }); + const family = createProviderWorkspaceSnapshotRefreshAtomFamily({ + providers: () => providersAtom, + refreshProviders: { label: "refresh", run }, + }); + const registry = AtomRegistry.make(); + const atom = family({ environmentId, instanceId: provider.instanceId, cwd }); + const unmount = registry.mount(atom); + return { registry, atom, family, providersAtom, responses, run, unmount }; + } + + it("scans a missing snapshot once and skips a repeat inside the TTL", async () => { + vi.useFakeTimers({ now: Date.parse("2026-01-01T00:00:00.000Z") }); + const { registry, atom, family, responses, run, unmount } = setup(noSnapshot); + // A second composer on the same target shares the scan. + registry.mount(family({ environmentId, instanceId: provider.instanceId, cwd }))(); + expect(run).toHaveBeenCalledTimes(1); + expect(run).toHaveBeenCalledWith(expect.anything(), { + environmentId, + input: { instanceId: provider.instanceId, cwd }, + }); + responses.push(() => withSnapshot("2026-01-01T00:00:00.000Z")); + await vi.advanceTimersByTimeAsync(0); + registry.refresh(atom); + expect(run).toHaveBeenCalledTimes(1); + unmount(); + }); + + it("does not rescan a current snapshot, and rescans once it ages past the TTL", () => { + vi.useFakeTimers({ now: Date.parse("2026-01-01T00:01:00.000Z") }); + const { registry, atom, run, unmount } = setup(withSnapshot("2026-01-01T00:00:00.000Z")); + expect(run).not.toHaveBeenCalled(); + vi.setSystemTime(Date.parse("2026-01-01T00:05:00.000Z")); + registry.refresh(atom); + expect(run).toHaveBeenCalledTimes(1); + unmount(); + }); + + it("cools down a failed scan, then retries on its own while the scan stays pending", async () => { + vi.useFakeTimers({ now: Date.parse("2026-01-01T00:00:00.000Z") }); + const { registry, atom, run, unmount } = setup(withSnapshot("2026-01-01T00:00:00.000Z", true)); + expect(run).toHaveBeenCalledTimes(1); + await vi.advanceTimersByTimeAsync(0); + registry.refresh(atom); + expect(run).toHaveBeenCalledTimes(1); + await vi.advanceTimersByTimeAsync(9_999); + expect(run).toHaveBeenCalledTimes(1); + await vi.advanceTimersByTimeAsync(1); + expect(run).toHaveBeenCalledTimes(2); + unmount(); + await vi.advanceTimersByTimeAsync(20_000); + expect(run).toHaveBeenCalledTimes(2); + expect(vi.getTimerCount()).toBe(0); + }); + + it("stops retrying after three failed scans until the TTL lapses", async () => { + vi.useFakeTimers({ now: Date.parse("2026-01-01T00:00:00.000Z") }); + const { registry, atom, run, unmount } = setup(withSnapshot("2026-01-01T00:00:00.000Z", true)); + await vi.advanceTimersByTimeAsync(60_000); + expect(run).toHaveBeenCalledTimes(3); + registry.refresh(atom); + await vi.advanceTimersByTimeAsync(0); + expect(run).toHaveBeenCalledTimes(3); + expect(vi.getTimerCount()).toBe(0); + vi.setSystemTime(Date.parse("2026-01-01T00:10:00.000Z")); + registry.refresh(atom); + expect(run).toHaveBeenCalledTimes(4); + unmount(); + }); + + it("does not retry on a timer when the failed scan left no pending snapshot", async () => { + vi.useFakeTimers({ now: Date.parse("2026-01-01T00:00:00.000Z") }); + const { registry, atom, run, unmount } = setup(noSnapshot); + await vi.advanceTimersByTimeAsync(10_000); + expect(run).toHaveBeenCalledTimes(1); + registry.refresh(atom); + expect(run).toHaveBeenCalledTimes(2); + unmount(); + }); + + it("rescans at once when the server drops the snapshot, lifting TTL and cooldown", async () => { + vi.useFakeTimers({ now: Date.parse("2026-01-01T00:00:00.000Z") }); + const { registry, providersAtom, run, unmount } = setup(noSnapshot); + await vi.advanceTimersByTimeAsync(0); + expect(run).toHaveBeenCalledTimes(1); + registry.set(providersAtom, withSnapshot("2026-01-01T00:00:00.000Z")); + expect(run).toHaveBeenCalledTimes(1); + registry.set(providersAtom, noSnapshot); + expect(run).toHaveBeenCalledTimes(2); + unmount(); + }); + + it("does not scan again when a scan fails after the last subscriber left", async () => { + vi.useFakeTimers({ now: Date.parse("2026-01-01T00:00:00.000Z") }); + let release = () => {}; + const gate = new Promise((resolve) => { + release = resolve; + }); + const { run, unmount } = setup(withSnapshot("2026-01-01T00:00:00.000Z", true), [ + async () => { + await gate; + return null; + }, + ]); + expect(run).toHaveBeenCalledTimes(1); + unmount(); + await vi.advanceTimersByTimeAsync(0); + release(); + await vi.advanceTimersByTimeAsync(20_000); + expect(run).toHaveBeenCalledTimes(1); + expect(vi.getTimerCount()).toBe(0); + }); + + it("ignores provider updates that leave the snapshot status unchanged", () => { + vi.useFakeTimers({ now: Date.parse("2026-01-01T00:01:00.000Z") }); + const { registry, providersAtom, run, unmount } = setup( + withSnapshot("2026-01-01T00:00:00.000Z"), + ); + vi.setSystemTime(Date.parse("2026-01-01T00:06:00.000Z")); + registry.set(providersAtom, withSnapshot("2026-01-01T00:00:00.000Z")); + expect(run).not.toHaveBeenCalled(); + unmount(); + }); +}); diff --git a/packages/client-runtime/src/providerSkills.ts b/packages/client-runtime/src/providerSkills.ts index 0bc0f8a6e8d50..e0e15450669f8 100644 --- a/packages/client-runtime/src/providerSkills.ts +++ b/packages/client-runtime/src/providerSkills.ts @@ -1,9 +1,16 @@ import { + type EnvironmentId, isProviderWorkspaceSnapshotCurrent, + PROVIDER_WORKSPACE_SNAPSHOT_TTL_MS, + type ProviderInstanceId, type ServerProvider, type ServerProviderSkill, type ServerProviderSlashCommand, } from "@t3tools/contracts"; +import { Atom } from "effect/reactivity"; + +import { deadlineStore } from "./clock.ts"; +import { type AtomCommand, runAtomCommand } from "./state/runtime.ts"; export type ProviderSkillSourceKind = "app" | "repo" | "project" | "personal" | "system" | "other"; @@ -98,7 +105,7 @@ export function hasCompleteProviderWorkspaceSnapshot( return Boolean(snapshot && !snapshot.slashCommandsPending); } -export function hasCurrentProviderWorkspaceSnapshot( +function hasCurrentProviderWorkspaceSnapshot( provider: ServerProvider | null | undefined, cwd: string | null | undefined, nowMs: number, @@ -124,3 +131,194 @@ export function resolveProviderSlashCommandsForCwd( ): ServerProvider["slashCommands"] { return resolveProviderWorkspaceSnapshot(provider, cwd)?.slashCommands ?? provider.slashCommands; } + +const WORKSPACE_SNAPSHOT_RETRY_COOLDOWN_MS = 10_000; +// A cwd that never scans, such as a deleted worktree, would otherwise retry forever. +const WORKSPACE_SNAPSHOT_MAX_ATTEMPTS = 3; + +interface ProviderWorkspaceSnapshotRefreshState { + /** + * When the last scan was requested. A request inside the TTL is not + * repeated, so a client clock ahead of the server's cannot loop rescans. + */ + readonly requestedAt: number | null; + /** A failed scan is not retried before this time. */ + readonly retryNotBefore: number | null; + /** Scans in a row that ended without a complete snapshot. */ + readonly failedAttempts: number; + readonly hadSnapshot: boolean; +} + +const initialProviderWorkspaceSnapshotRefreshState: ProviderWorkspaceSnapshotRefreshState = { + requestedAt: null, + retryNotBefore: null, + failedAttempts: 0, + hadSnapshot: false, +}; + +/** + * Decides whether a composer looking at `cwd` should ask the server to rescan + * the provider's workspace, and when to look again if a retry is due while the + * server still reports the scan as pending. + */ +function planProviderWorkspaceSnapshotRefresh( + previous: ProviderWorkspaceSnapshotRefreshState, + provider: ServerProvider | null | undefined, + cwd: string, + now: number, +): { + readonly state: ProviderWorkspaceSnapshotRefreshState; + readonly refresh: boolean; + readonly recheckAt: number | null; +} { + const hasSnapshot = hasCompleteProviderWorkspaceSnapshot(provider, cwd); + // Losing a snapshot (the server dropped it) lifts both the TTL and the retry + // cooldown so the scan happens at once. + const lost = previous.hadSnapshot && !hasSnapshot; + const requestedAt = lost ? null : previous.requestedAt; + const retryNotBefore = + lost || (previous.retryNotBefore !== null && now >= previous.retryNotBefore) + ? null + : previous.retryNotBefore; + const state = { + requestedAt, + retryNotBefore, + // A complete snapshot ends the run of failures. + failedAttempts: hasSnapshot ? 0 : previous.failedAttempts, + hadSnapshot: hasSnapshot, + }; + const refresh = + (requestedAt === null || now - requestedAt >= PROVIDER_WORKSPACE_SNAPSHOT_TTL_MS) && + !hasCurrentProviderWorkspaceSnapshot(provider, cwd, now) && + retryNotBefore === null; + if (refresh) { + return { state: { ...state, requestedAt: now }, refresh: true, recheckAt: null }; + } + const pending = + (provider && resolveProviderWorkspaceSnapshot(provider, cwd)?.slashCommandsPending) === true; + return { state, refresh: false, recheckAt: pending ? retryNotBefore : null }; +} + +/** + * A scan that failed, or finished without a complete snapshot, cools down + * before retrying. After the last attempt the request marker stays, so only + * the TTL (or a snapshot loss) lets the next scan through. + */ +function failProviderWorkspaceSnapshotRefresh( + state: ProviderWorkspaceSnapshotRefreshState, + requestedAt: number, + now: number, +): ProviderWorkspaceSnapshotRefreshState { + // A newer request, or a snapshot loss, already superseded this one. + if (state.requestedAt !== requestedAt) return state; + const failedAttempts = state.failedAttempts + 1; + if (failedAttempts >= WORKSPACE_SNAPSHOT_MAX_ATTEMPTS) return { ...state, failedAttempts }; + return { + ...state, + requestedAt: null, + retryNotBefore: now + WORKSPACE_SNAPSHOT_RETRY_COOLDOWN_MS, + failedAttempts, + }; +} + +type WorkspaceSnapshotStatus = "absent" | "pending" | "complete"; + +function resolveWorkspaceSnapshotStatus( + provider: ServerProvider | undefined, + cwd: string, +): WorkspaceSnapshotStatus { + const snapshot = provider && resolveProviderWorkspaceSnapshot(provider, cwd); + if (!snapshot) return "absent"; + return snapshot.slashCommandsPending ? "pending" : "complete"; +} + +interface ProviderWorkspaceSnapshotRefreshTarget { + readonly environmentId: EnvironmentId; + readonly instanceId: ProviderInstanceId; + readonly cwd: string; +} + +/** + * Keeps one environment/provider/cwd workspace snapshot fresh while mounted: + * reading scans when it is missing or stale, and refreshing re-checks (the + * composer does as the user types). It owns the retry timer, so the timer dies + * with the last subscriber. + */ +export function createProviderWorkspaceSnapshotRefreshAtomFamily(input: { + readonly providers: ( + environmentId: EnvironmentId, + ) => Atom.Atom | null>; + readonly refreshProviders: AtomCommand< + { + readonly environmentId: EnvironmentId; + readonly input: { readonly instanceId: ProviderInstanceId; readonly cwd: string }; + }, + { readonly providers: ReadonlyArray }, + unknown + >; +}) { + // Plain-object keys compare structurally (Effect `Equal`/`Hash`). + const family = Atom.family((target: ProviderWorkspaceSnapshotRefreshTarget) => { + const { environmentId, instanceId, cwd } = target; + const key = `${environmentId}:${instanceId}:${cwd}`; + const findProvider = (providers: ReadonlyArray | null) => + providers?.find((provider) => provider.instanceId === instanceId); + const statusAtom = Atom.make((get) => + resolveWorkspaceSnapshotStatus(findProvider(get(input.providers(environmentId))), cwd), + ).pipe(Atom.withLabel(`provider-workspace-snapshot-status:${key}`)); + type Update = ( + state: ProviderWorkspaceSnapshotRefreshState, + ) => ProviderWorkspaceSnapshotRefreshState; + const refreshAtom: Atom.Writable = Atom.writable( + (get): ProviderWorkspaceSnapshotRefreshState => { + get(statusAtom); + // `once`: unrelated provider updates must not trigger a check. + const provider = findProvider(get.once(input.providers(environmentId))); + // @effect-diagnostics-next-line globalDate:off + const now = Date.now(); + const previous = get.self(); + const plan = planProviderWorkspaceSnapshotRefresh( + previous._tag === "Some" ? previous.value : initialProviderWorkspaceSnapshotRefreshState, + provider, + cwd, + now, + ); + if (plan.recheckAt !== null) { + get.addFinalizer(deadlineStore(plan.recheckAt).subscribe(() => get.refreshSelf())); + } + if (!plan.refresh) return plan.state; + const registry = get.registry; + // Outlives this build on purpose: a rebuild must not drop the retry. + // Once nothing subscribes, the retry state dies with the node; writing + // would recreate and rebuild it, which scans again with no subscriber. + const retryLater = () => { + const node = registry.getNodes().get(refreshAtom); + if (node === undefined || node.canBeRemoved) return; + registry.set(refreshAtom, (state) => + // @effect-diagnostics-next-line globalDate:off + failProviderWorkspaceSnapshotRefresh(state, now, Date.now()), + ); + }; + void runAtomCommand( + registry, + input.refreshProviders, + { environmentId, input: { instanceId, cwd } }, + { reportFailure: false }, + ).then((result) => { + const refreshed = + result._tag === "Success" && + hasCompleteProviderWorkspaceSnapshot(findProvider(result.value.providers), cwd); + if (!refreshed) retryLater(); + }, retryLater); + return plan.state; + }, + (ctx, update: Update) => { + ctx.setSelf(update(ctx.get(refreshAtom))); + ctx.refreshSelf(); + }, + ).pipe(Atom.withLabel(`provider-workspace-snapshot-refresh:${key}`)); + return refreshAtom; + }); + return (target: ProviderWorkspaceSnapshotRefreshTarget): Atom.Atom => + family({ environmentId: target.environmentId, instanceId: target.instanceId, cwd: target.cwd }); +} From 9e1af95bdd7b4b6a485fe0487506f708670dbde5 Mon Sep 17 00:00:00 2001 From: Julius Marminge <51714798+juliusmarminge@users.noreply.github.com> Date: Mon, 26 Oct 2026 01:49:24 -0700 Subject: [PATCH 2/2] fix(mobile): draft changes from other surfaces recheck the workspace snapshot Co-Authored-By: Claude Opus 5.5 --- .../src/features/threads/use-composer-command-menu.ts | 11 +++++++++-- 1 file changed, 9 insertions(+), 2 deletions(-) diff --git a/apps/mobile/src/features/threads/use-composer-command-menu.ts b/apps/mobile/src/features/threads/use-composer-command-menu.ts index 6c687e03afeea..d1aba9ccf9b2a 100644 --- a/apps/mobile/src/features/threads/use-composer-command-menu.ts +++ b/apps/mobile/src/features/threads/use-composer-command-menu.ts @@ -254,10 +254,17 @@ export function useComposerCommandMenu({ }) : idleWorkspaceSnapshotRefreshAtom, ); - // Selection changes on every keystroke, which is when a stale snapshot is rechecked. + // Every draft change rechecks a stale snapshot, including drafts written by + // another surface (a prompt link) that never move the editor selection. + useEffect(() => { + refreshWorkspaceSnapshot(); + }, [ + // oxlint-disable-next-line react/exhaustive-effect-dependencies -- Draft changes are the recheck signal. + draftMessage, + refreshWorkspaceSnapshot, + ]); const onSelectionChange = (nextSelection: ComposerEditorSelection) => { setSelection(nextSelection); - refreshWorkspaceSnapshot(); }; const trigger = (() => {