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 722cb1d5852bd2c1bbe05b6ca1472dfa23230b76..a02b95b50666d271402c637f8fbf106f65ce87f4 100644 --- a/apps/mobile/src/features/threads/use-composer-command-menu.ts +++ b/apps/mobile/src/features/threads/use-composer-command-menu.ts @@ -9,7 +9,10 @@ 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 } from "@t3tools/contracts"; +import { + COMPOSER_CONTEXT_MAX_RECORDS, + PROVIDER_WORKSPACE_SNAPSHOT_TTL_MS, +} from "@t3tools/contracts"; import { Alert } from "react-native"; import { formatComposerContextReference } from "@t3tools/shared/composerContextReferences"; import { pullRequestComposerContext, threadComposerContext } from "../../lib/composerContext"; @@ -37,6 +40,7 @@ import { getProviderSlashCommandsForSlashMenu, isProviderSkillUserInvocable, hasCompleteProviderWorkspaceSnapshot, + hasCurrentProviderWorkspaceSnapshot, resolveProviderSkillsForCwd, resolveProviderSlashCommandsForCwd, } from "@t3tools/client-runtime/providerSkills"; @@ -250,7 +254,9 @@ export function useComposerCommandMenu({ selectedProviderStatus, projectCwd, ); - const workspaceRefreshKeyRef = useRef(null); + // 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 [workspaceRefreshRetry, setWorkspaceRefreshRetry] = useState<{ key: string; notBefore: number; @@ -289,17 +295,23 @@ export function useComposerCommandMenu({ useEffect(() => { if (!environmentId || !projectCwd || !selectedProviderInstanceId) return; const key = `${environmentId}:${selectedProviderInstanceId}:${projectCwd}`; - if (workspaceRefreshKeyRef.current === key) return; - if (hasWorkspaceSnapshot) { - workspaceRefreshKeyRef.current = key; + 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)) { setWorkspaceRefreshRetry(null); return; } const retry = workspaceRefreshRetry; - if (retry?.key === key && Date.now() < retry.notBefore) return; - workspaceRefreshKeyRef.current = key; + if (retry?.key === key && now < retry.notBefore) return; + const request = { key, requestedAt: now }; + workspaceRefreshKeyRef.current = request; const retryLater = () => { - if (workspaceRefreshKeyRef.current !== key) return; + if (workspaceRefreshKeyRef.current !== request) return; workspaceRefreshKeyRef.current = null; setWorkspaceRefreshRetry({ key, @@ -318,17 +330,15 @@ export function useComposerCommandMenu({ ), projectCwd, ); - if (!refreshed && workspaceRefreshKeyRef.current === key) { - retryLater(); - } + if (!refreshed) retryLater(); }, retryLater); }, [ draftMessage, environmentId, - hasWorkspaceSnapshot, projectCwd, refreshProviders, selectedProviderInstanceId, + selectedProviderStatus, workspaceRefreshRetry, ]); diff --git a/apps/server/src/provider/ProviderRegistry.test.ts b/apps/server/src/provider/ProviderRegistry.test.ts index 3e461f5f1a5b1355e55036b683fcf68affcdb452..155eed099f253fc345cbbcc17358c02a406ad075 100644 --- a/apps/server/src/provider/ProviderRegistry.test.ts +++ b/apps/server/src/provider/ProviderRegistry.test.ts @@ -23,6 +23,7 @@ import { ClaudeSettings, CodexSettings, DEFAULT_SERVER_SETTINGS, + PROVIDER_WORKSPACE_SNAPSHOT_TTL_MS, ProviderDriverKind, ProviderInstanceId, ServerSettings, @@ -1768,6 +1769,13 @@ it.layer( ); yield* registry.refreshWorkspaceSnapshot({ instanceId, cwd: "/workspace" }); assert.strictEqual(yield* Ref.get(snapshotCalls), 3); + // Nothing watches skill directories, so an expired scan is redone on use. + yield* TestClock.adjust(PROVIDER_WORKSPACE_SNAPSHOT_TTL_MS - 1); + yield* registry.refreshWorkspaceSnapshot({ instanceId, cwd: "/workspace" }); + assert.strictEqual(yield* Ref.get(snapshotCalls), 3); + yield* TestClock.adjust(1); + yield* registry.refreshWorkspaceSnapshot({ instanceId, cwd: "/workspace" }); + assert.strictEqual(yield* Ref.get(snapshotCalls), 4); const newSkills = [ ...scopedProvider.skills, { name: "added", path: "/workspace/added/SKILL.md", enabled: true }, @@ -1778,7 +1786,7 @@ it.layer( cwd: "/workspace", fresh: true, }); - assert.strictEqual(yield* Ref.get(snapshotCalls), 4); + assert.strictEqual(yield* Ref.get(snapshotCalls), 5); assert.strictEqual(yield* Ref.get(cacheInvalidations), 1); assert.deepStrictEqual( (yield* registry.getProviders)[0]?.workspaceSnapshots?.map((s) => s.skills), diff --git a/apps/server/src/provider/ProviderRegistry.ts b/apps/server/src/provider/ProviderRegistry.ts index 65c56525d329402ca6f6cc47b735480d0b5be1ca..97b80f270e90b87a4e1f78c8c74215496fb8df49 100644 --- a/apps/server/src/provider/ProviderRegistry.ts +++ b/apps/server/src/provider/ProviderRegistry.ts @@ -29,6 +29,7 @@ */ import { defaultInstanceIdForDriver, + isProviderWorkspaceSnapshotCurrent, ProviderDriverKind, type ProviderInstanceId, type ServerProvider, @@ -36,6 +37,7 @@ import { } from "@t3tools/contracts"; import * as Cause from "effect/Cause"; import * as Context from "effect/Context"; +import * as DateTime from "effect/DateTime"; import * as Deferred from "effect/Deferred"; import * as Effect from "effect/Effect"; import * as Equal from "effect/Equal"; @@ -1048,13 +1050,20 @@ export const layer = Layer.effect( const workspaceSnapshotOf = (candidate: ServerProvider | undefined) => candidate?.workspaceSnapshots?.find((s) => s.cwd === input.cwd); const scannedFrom = workspaceSnapshotOf(provider); + const now = yield* DateTime.now; if ( !provider || !provider.enabled || - (!input.fresh && scannedFrom && !scannedFrom.slashCommandsPending) + (!input.fresh && + scannedFrom && + !scannedFrom.slashCommandsPending && + isProviderWorkspaceSnapshotCurrent(scannedFrom, DateTime.toEpochMillis(now))) ) { return providers; } + // Drivers spread their machine snapshot, whose `checkedAt` is the last + // health check. The TTL needs the time this scan started reading files. + const scannedAt = DateTime.formatIso(now); const instance = yield* instanceRegistry.getInstance(input.instanceId); if (!instance?.snapshotForCwd) return providers; const claimed = yield* Ref.modify(workspaceRefreshesRef, (refreshes) => { @@ -1086,7 +1095,10 @@ export const layer = Layer.effect( currentProviders.map((candidate) => candidate.instanceId === input.instanceId && Equal.equals(workspaceSnapshotOf(candidate), scannedFrom) - ? upsertProviderWorkspaceSnapshot(candidate, input.cwd, scopedSnapshot) + ? upsertProviderWorkspaceSnapshot(candidate, input.cwd, { + ...scopedSnapshot, + checkedAt: scannedAt, + }) : candidate, ), ); diff --git a/apps/web/src/components/chat/ChatComposer.tsx b/apps/web/src/components/chat/ChatComposer.tsx index b3be7790bce47ba1baf2239108c2b771abb12daa..79e652c15d49f847e5f89fd5070d9f930fe11e94 100644 --- a/apps/web/src/components/chat/ChatComposer.tsx +++ b/apps/web/src/components/chat/ChatComposer.tsx @@ -44,6 +44,7 @@ import { PROVIDER_SEND_TURN_MAX_ATTACHMENTS, PROVIDER_SEND_TURN_MAX_IMAGE_BYTES, PROVIDER_SEND_TURN_MAX_INPUT_CHARS, + PROVIDER_WORKSPACE_SNAPSHOT_TTL_MS, } from "@t3tools/contracts"; import type { EnvironmentConnectionPresentation } from "@t3tools/client-runtime/connection"; import { @@ -1123,6 +1124,7 @@ import { getProviderSlashCommandsForSlashMenu, getProviderSkillsForSlashMenu, hasCompleteProviderWorkspaceSnapshot, + hasCurrentProviderWorkspaceSnapshot, resolveProviderSkillsForCwd, resolveProviderSlashCommandsForCwd, } from "@t3tools/client-runtime/providerSkills"; @@ -2194,7 +2196,9 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) const refreshProviders = useAtomCommand(serverEnvironment.refreshProviders, { reportFailure: false, }); - const workspaceRefreshKeyRef = useRef(null); + // 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 [workspaceRefreshRetry, setWorkspaceRefreshRetry] = useState<{ key: string; notBefore: number; @@ -2237,21 +2241,23 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) useEffect(() => { if (!gitCwd || !selectedProviderEntry) return; const key = `${environmentId}:${selectedProviderEntry.instanceId}:${gitCwd}`; - const hasWorkspaceSnapshot = hasCompleteProviderWorkspaceSnapshot( - selectedProviderStatus, - gitCwd, - ); - if (workspaceRefreshKeyRef.current === key) return; - if (hasWorkspaceSnapshot) { - workspaceRefreshKeyRef.current = key; + const now = Date.now(); + const lastRequest = workspaceRefreshKeyRef.current; + if ( + lastRequest?.key === key && + now - lastRequest.requestedAt < PROVIDER_WORKSPACE_SNAPSHOT_TTL_MS + ) + return; + if (hasCurrentProviderWorkspaceSnapshot(selectedProviderStatus, gitCwd, now)) { setWorkspaceRefreshRetry(null); return; } const retry = workspaceRefreshRetry; - if (retry?.key === key && Date.now() < retry.notBefore) return; - workspaceRefreshKeyRef.current = key; + if (retry?.key === key && now < retry.notBefore) return; + const request = { key, requestedAt: now }; + workspaceRefreshKeyRef.current = request; const retryLater = () => { - if (workspaceRefreshKeyRef.current !== key) return; + if (workspaceRefreshKeyRef.current !== request) return; workspaceRefreshKeyRef.current = null; setWorkspaceRefreshRetry({ key, @@ -2270,9 +2276,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) ), gitCwd, ); - if (!hasWorkspaceSnapshot && workspaceRefreshKeyRef.current === key) { - retryLater(); - } + if (!hasWorkspaceSnapshot) retryLater(); }, retryLater); }, [ environmentId, diff --git a/packages/client-runtime/src/providerSkills.test.ts b/packages/client-runtime/src/providerSkills.test.ts index 30b804d176ac30cd743913ad7ee8ae61a351ff4b..d5c8933f0bf4d191259cb0a2418802487c89f861 100644 --- a/packages/client-runtime/src/providerSkills.test.ts +++ b/packages/client-runtime/src/providerSkills.test.ts @@ -1,4 +1,9 @@ -import { ProviderDriverKind, ProviderInstanceId, type ServerProvider } from "@t3tools/contracts"; +import { + PROVIDER_WORKSPACE_SNAPSHOT_TTL_MS, + ProviderDriverKind, + ProviderInstanceId, + type ServerProvider, +} from "@t3tools/contracts"; import { describe, expect, it } from "vite-plus/test"; import { @@ -7,6 +12,7 @@ import { getProviderSlashCommandsForSlashMenu, getProviderSkillsForSlashMenu, hasCompleteProviderWorkspaceSnapshot, + hasCurrentProviderWorkspaceSnapshot, resolveProviderSkillsForCwd, resolveProviderSlashCommandsForCwd, resolveProviderSkillSourceKind, @@ -274,4 +280,27 @@ describe("workspace provider snapshots", () => { expect(hasCompleteProviderWorkspaceSnapshot(undefined, "/workspace/project-a")).toBe(false); expect(hasCompleteProviderWorkspaceSnapshot(provider, null)).toBe(false); }); + + it("asks for a rescan once the workspace snapshot outlives its TTL", () => { + const scannedAt = Date.parse("2026-01-01T00:01:00.000Z"); + const cwd = "/workspace/project-a"; + expect(hasCurrentProviderWorkspaceSnapshot(provider, cwd, scannedAt)).toBe(true); + expect( + hasCurrentProviderWorkspaceSnapshot( + provider, + cwd, + scannedAt + PROVIDER_WORKSPACE_SNAPSHOT_TTL_MS - 1, + ), + ).toBe(true); + expect( + hasCurrentProviderWorkspaceSnapshot( + provider, + cwd, + scannedAt + PROVIDER_WORKSPACE_SNAPSHOT_TTL_MS, + ), + ).toBe(false); + expect(hasCurrentProviderWorkspaceSnapshot(provider, "/workspace/project-b", scannedAt)).toBe( + false, + ); + }); }); diff --git a/packages/client-runtime/src/providerSkills.ts b/packages/client-runtime/src/providerSkills.ts index b52377b0165e308caa11f686add8e2e81aeb7fe9..4d649cd19f11365543d5d82482c92aed40e28817 100644 --- a/packages/client-runtime/src/providerSkills.ts +++ b/packages/client-runtime/src/providerSkills.ts @@ -1,7 +1,8 @@ -import type { - ServerProvider, - ServerProviderSkill, - ServerProviderSlashCommand, +import { + isProviderWorkspaceSnapshotCurrent, + type ServerProvider, + type ServerProviderSkill, + type ServerProviderSlashCommand, } from "@t3tools/contracts"; export type ProviderSkillSourceKind = "app" | "repo" | "project" | "personal" | "system" | "other"; @@ -119,6 +120,20 @@ export function hasCompleteProviderWorkspaceSnapshot( return Boolean(snapshot && !snapshot.slashCommandsPending); } +/** A complete snapshot young enough that opening a composer need not rescan. */ +export function hasCurrentProviderWorkspaceSnapshot( + provider: ServerProvider | null | undefined, + cwd: string | null | undefined, + nowMs: number, +): boolean { + const snapshot = provider && resolveProviderWorkspaceSnapshot(provider, cwd); + return Boolean( + snapshot && + !snapshot.slashCommandsPending && + isProviderWorkspaceSnapshotCurrent(snapshot, nowMs), + ); +} + export function resolveProviderSkillsForCwd( provider: ServerProvider, cwd: string | null | undefined, diff --git a/packages/contracts/src/server.ts b/packages/contracts/src/server.ts index 68ffef7d110b32146da524009c34eb5f48e0cc51..c46c8b732a6c71d236e6dc6a0f990eaefe19faf4 100644 --- a/packages/contracts/src/server.ts +++ b/packages/contracts/src/server.ts @@ -131,6 +131,20 @@ export const ServerProviderWorkspaceSnapshot = Schema.Struct({ }); export type ServerProviderWorkspaceSnapshot = typeof ServerProviderWorkspaceSnapshot.Type; +/** + * How long a workspace's skill and command scan stays current. Nothing watches + * skill directories, so a composer opened after this rescans on use, and the + * server answers repeat requests inside the window from its cache. + */ +export const PROVIDER_WORKSPACE_SNAPSHOT_TTL_MS = 5 * 60_000; + +export function isProviderWorkspaceSnapshotCurrent( + snapshot: Pick, + nowMs: number, +): boolean { + return nowMs - Date.parse(snapshot.checkedAt) < PROVIDER_WORKSPACE_SNAPSHOT_TTL_MS; +} + /** * Availability of a configured provider instance from the runtime's POV. *