authorgravatar for julius0216@outlook.comJulius Marminge <julius0216@outlook.com> 2026-10-07 00:04:13-07:00
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-10-07 00:04:13-07:00
loga1830915ecf70e3ad4fbe8efb15ae9f18517eb54
tree2d8b57669d0ead856dad2442527a3f372227fec5
parent4b063a042ec93418a196c020b852459f57e17b57
signature Signed by PGP key B5690EEEBB952194

fix: composer picks up new project skills without a server restart (#16750)


7 files changed, 126 insertions(+), 34 deletions(-)

apps/mobile/src/features/threads/use-composer-command-menu.ts+22-12
...@@ -9,7 +9,10 @@ import { matchComposerThreadItems } from "@t3tools/client-runtime/composerThread...@@ -9,7 +9,10 @@ import { matchComposerThreadItems } from "@t3tools/client-runtime/composerThread
9import type { EnvironmentThreadShell } from "@t3tools/client-runtime/state/models";9import type { EnvironmentThreadShell } from "@t3tools/client-runtime/state/models";
1010
11const EMPTY_THREAD_SHELLS: ReadonlyArray<EnvironmentThreadShell> = [];11const EMPTY_THREAD_SHELLS: ReadonlyArray<EnvironmentThreadShell> = [];
12import { COMPOSER_CONTEXT_MAX_RECORDS } from "@t3tools/contracts";12import {
13 COMPOSER_CONTEXT_MAX_RECORDS,
14 PROVIDER_WORKSPACE_SNAPSHOT_TTL_MS,
15} from "@t3tools/contracts";
13import { Alert } from "react-native";16import { Alert } from "react-native";
14import { formatComposerContextReference } from "@t3tools/shared/composerContextReferences";17import { formatComposerContextReference } from "@t3tools/shared/composerContextReferences";
15import { pullRequestComposerContext, threadComposerContext } from "../../lib/composerContext";18import { pullRequestComposerContext, threadComposerContext } from "../../lib/composerContext";
...@@ -37,6 +40,7 @@ import {...@@ -37,6 +40,7 @@ import {
37 getProviderSlashCommandsForSlashMenu,40 getProviderSlashCommandsForSlashMenu,
38 isProviderSkillUserInvocable,41 isProviderSkillUserInvocable,
39 hasCompleteProviderWorkspaceSnapshot,42 hasCompleteProviderWorkspaceSnapshot,
43 hasCurrentProviderWorkspaceSnapshot,
40 resolveProviderSkillsForCwd,44 resolveProviderSkillsForCwd,
41 resolveProviderSlashCommandsForCwd,45 resolveProviderSlashCommandsForCwd,
42} from "@t3tools/client-runtime/providerSkills";46} from "@t3tools/client-runtime/providerSkills";
...@@ -250,7 +254,9 @@ export function useComposerCommandMenu({...@@ -250,7 +254,9 @@ export function useComposerCommandMenu({
250 selectedProviderStatus,254 selectedProviderStatus,
251 projectCwd,255 projectCwd,
252 );256 );
253 const workspaceRefreshKeyRef = useRef<string | null>(null);257 // The last scan this composer asked for. A request inside the TTL is not
258 // repeated, so a client clock ahead of the server's cannot loop rescans.
259 const workspaceRefreshKeyRef = useRef<{ key: string; requestedAt: number } | null>(null);
254 const [workspaceRefreshRetry, setWorkspaceRefreshRetry] = useState<{260 const [workspaceRefreshRetry, setWorkspaceRefreshRetry] = useState<{
255 key: string;261 key: string;
256 notBefore: number;262 notBefore: number;
...@@ -289,17 +295,23 @@ export function useComposerCommandMenu({...@@ -289,17 +295,23 @@ export function useComposerCommandMenu({
289 useEffect(() => {295 useEffect(() => {
290 if (!environmentId || !projectCwd || !selectedProviderInstanceId) return;296 if (!environmentId || !projectCwd || !selectedProviderInstanceId) return;
291 const key = `${environmentId}:${selectedProviderInstanceId}:${projectCwd}`;297 const key = `${environmentId}:${selectedProviderInstanceId}:${projectCwd}`;
292 if (workspaceRefreshKeyRef.current === key) return;298 const now = Date.now();
293 if (hasWorkspaceSnapshot) {299 const lastRequest = workspaceRefreshKeyRef.current;
294 workspaceRefreshKeyRef.current = key;300 if (
301 lastRequest?.key === key &&
302 now - lastRequest.requestedAt < PROVIDER_WORKSPACE_SNAPSHOT_TTL_MS
303 )
304 return;
305 if (hasCurrentProviderWorkspaceSnapshot(selectedProviderStatus, projectCwd, now)) {
295 setWorkspaceRefreshRetry(null);306 setWorkspaceRefreshRetry(null);
296 return;307 return;
297 }308 }
298 const retry = workspaceRefreshRetry;309 const retry = workspaceRefreshRetry;
299 if (retry?.key === key && Date.now() < retry.notBefore) return;310 if (retry?.key === key && now < retry.notBefore) return;
300 workspaceRefreshKeyRef.current = key;311 const request = { key, requestedAt: now };
312 workspaceRefreshKeyRef.current = request;
301 const retryLater = () => {313 const retryLater = () => {
302 if (workspaceRefreshKeyRef.current !== key) return;314 if (workspaceRefreshKeyRef.current !== request) return;
303 workspaceRefreshKeyRef.current = null;315 workspaceRefreshKeyRef.current = null;
304 setWorkspaceRefreshRetry({316 setWorkspaceRefreshRetry({
305 key,317 key,
...@@ -318,17 +330,15 @@ export function useComposerCommandMenu({...@@ -318,17 +330,15 @@ export function useComposerCommandMenu({
318 ),330 ),
319 projectCwd,331 projectCwd,
320 );332 );
321 if (!refreshed && workspaceRefreshKeyRef.current === key) {333 if (!refreshed) retryLater();
322 retryLater();
323 }
324 }, retryLater);334 }, retryLater);
325 }, [335 }, [
326 draftMessage,336 draftMessage,
327 environmentId,337 environmentId,
328 hasWorkspaceSnapshot,
329 projectCwd,338 projectCwd,
330 refreshProviders,339 refreshProviders,
331 selectedProviderInstanceId,340 selectedProviderInstanceId,
341 selectedProviderStatus,
332 workspaceRefreshRetry,342 workspaceRefreshRetry,
333 ]);343 ]);
334344
apps/server/src/provider/ProviderRegistry.test.ts+9-1
...@@ -23,6 +23,7 @@ import {...@@ -23,6 +23,7 @@ import {
23 ClaudeSettings,23 ClaudeSettings,
24 CodexSettings,24 CodexSettings,
25 DEFAULT_SERVER_SETTINGS,25 DEFAULT_SERVER_SETTINGS,
26 PROVIDER_WORKSPACE_SNAPSHOT_TTL_MS,
26 ProviderDriverKind,27 ProviderDriverKind,
27 ProviderInstanceId,28 ProviderInstanceId,
28 ServerSettings,29 ServerSettings,
...@@ -1768,6 +1769,13 @@ it.layer(...@@ -1768,6 +1769,13 @@ it.layer(
1768 );1769 );
1769 yield* registry.refreshWorkspaceSnapshot({ instanceId, cwd: "/workspace" });1770 yield* registry.refreshWorkspaceSnapshot({ instanceId, cwd: "/workspace" });
1770 assert.strictEqual(yield* Ref.get(snapshotCalls), 3);1771 assert.strictEqual(yield* Ref.get(snapshotCalls), 3);
1772 // Nothing watches skill directories, so an expired scan is redone on use.
1773 yield* TestClock.adjust(PROVIDER_WORKSPACE_SNAPSHOT_TTL_MS - 1);
1774 yield* registry.refreshWorkspaceSnapshot({ instanceId, cwd: "/workspace" });
1775 assert.strictEqual(yield* Ref.get(snapshotCalls), 3);
1776 yield* TestClock.adjust(1);
1777 yield* registry.refreshWorkspaceSnapshot({ instanceId, cwd: "/workspace" });
1778 assert.strictEqual(yield* Ref.get(snapshotCalls), 4);
1771 const newSkills = [1779 const newSkills = [
1772 ...scopedProvider.skills,1780 ...scopedProvider.skills,
1773 { name: "added", path: "/workspace/added/SKILL.md", enabled: true },1781 { name: "added", path: "/workspace/added/SKILL.md", enabled: true },
...@@ -1778,7 +1786,7 @@ it.layer(...@@ -1778,7 +1786,7 @@ it.layer(
1778 cwd: "/workspace",1786 cwd: "/workspace",
1779 fresh: true,1787 fresh: true,
1780 });1788 });
1781 assert.strictEqual(yield* Ref.get(snapshotCalls), 4);1789 assert.strictEqual(yield* Ref.get(snapshotCalls), 5);
1782 assert.strictEqual(yield* Ref.get(cacheInvalidations), 1);1790 assert.strictEqual(yield* Ref.get(cacheInvalidations), 1);
1783 assert.deepStrictEqual(1791 assert.deepStrictEqual(
1784 (yield* registry.getProviders)[0]?.workspaceSnapshots?.map((s) => s.skills),1792 (yield* registry.getProviders)[0]?.workspaceSnapshots?.map((s) => s.skills),
apps/server/src/provider/ProviderRegistry.ts+14-2
...@@ -29,6 +29,7 @@...@@ -29,6 +29,7 @@
29 */29 */
30import {30import {
31 defaultInstanceIdForDriver,31 defaultInstanceIdForDriver,
32 isProviderWorkspaceSnapshotCurrent,
32 ProviderDriverKind,33 ProviderDriverKind,
33 type ProviderInstanceId,34 type ProviderInstanceId,
34 type ServerProvider,35 type ServerProvider,
...@@ -36,6 +37,7 @@ import {...@@ -36,6 +37,7 @@ import {
36} from "@t3tools/contracts";37} from "@t3tools/contracts";
37import * as Cause from "effect/Cause";38import * as Cause from "effect/Cause";
38import * as Context from "effect/Context";39import * as Context from "effect/Context";
40import * as DateTime from "effect/DateTime";
39import * as Deferred from "effect/Deferred";41import * as Deferred from "effect/Deferred";
40import * as Effect from "effect/Effect";42import * as Effect from "effect/Effect";
41import * as Equal from "effect/Equal";43import * as Equal from "effect/Equal";
...@@ -1048,13 +1050,20 @@ export const layer = Layer.effect(...@@ -1048,13 +1050,20 @@ export const layer = Layer.effect(
1048 const workspaceSnapshotOf = (candidate: ServerProvider | undefined) =>1050 const workspaceSnapshotOf = (candidate: ServerProvider | undefined) =>
1049 candidate?.workspaceSnapshots?.find((s) => s.cwd === input.cwd);1051 candidate?.workspaceSnapshots?.find((s) => s.cwd === input.cwd);
1050 const scannedFrom = workspaceSnapshotOf(provider);1052 const scannedFrom = workspaceSnapshotOf(provider);
1053 const now = yield* DateTime.now;
1051 if (1054 if (
1052 !provider ||1055 !provider ||
1053 !provider.enabled ||1056 !provider.enabled ||
1054 (!input.fresh && scannedFrom && !scannedFrom.slashCommandsPending)1057 (!input.fresh &&
1058 scannedFrom &&
1059 !scannedFrom.slashCommandsPending &&
1060 isProviderWorkspaceSnapshotCurrent(scannedFrom, DateTime.toEpochMillis(now)))
1055 ) {1061 ) {
1056 return providers;1062 return providers;
1057 }1063 }
1064 // Drivers spread their machine snapshot, whose `checkedAt` is the last
1065 // health check. The TTL needs the time this scan started reading files.
1066 const scannedAt = DateTime.formatIso(now);
1058 const instance = yield* instanceRegistry.getInstance(input.instanceId);1067 const instance = yield* instanceRegistry.getInstance(input.instanceId);
1059 if (!instance?.snapshotForCwd) return providers;1068 if (!instance?.snapshotForCwd) return providers;
1060 const claimed = yield* Ref.modify(workspaceRefreshesRef, (refreshes) => {1069 const claimed = yield* Ref.modify(workspaceRefreshesRef, (refreshes) => {
...@@ -1086,7 +1095,10 @@ export const layer = Layer.effect(...@@ -1086,7 +1095,10 @@ export const layer = Layer.effect(
1086 currentProviders.map((candidate) =>1095 currentProviders.map((candidate) =>
1087 candidate.instanceId === input.instanceId &&1096 candidate.instanceId === input.instanceId &&
1088 Equal.equals(workspaceSnapshotOf(candidate), scannedFrom)1097 Equal.equals(workspaceSnapshotOf(candidate), scannedFrom)
1089 ? upsertProviderWorkspaceSnapshot(candidate, input.cwd, scopedSnapshot)1098 ? upsertProviderWorkspaceSnapshot(candidate, input.cwd, {
1099 ...scopedSnapshot,
1100 checkedAt: scannedAt,
1101 })
1090 : candidate,1102 : candidate,
1091 ),1103 ),
1092 );1104 );
apps/web/src/components/chat/ChatComposer.tsx+18-14
...@@ -44,6 +44,7 @@ import {...@@ -44,6 +44,7 @@ import {
44 PROVIDER_SEND_TURN_MAX_ATTACHMENTS,44 PROVIDER_SEND_TURN_MAX_ATTACHMENTS,
45 PROVIDER_SEND_TURN_MAX_IMAGE_BYTES,45 PROVIDER_SEND_TURN_MAX_IMAGE_BYTES,
46 PROVIDER_SEND_TURN_MAX_INPUT_CHARS,46 PROVIDER_SEND_TURN_MAX_INPUT_CHARS,
47 PROVIDER_WORKSPACE_SNAPSHOT_TTL_MS,
47} from "@t3tools/contracts";48} from "@t3tools/contracts";
48import type { EnvironmentConnectionPresentation } from "@t3tools/client-runtime/connection";49import type { EnvironmentConnectionPresentation } from "@t3tools/client-runtime/connection";
49import {50import {
...@@ -1123,6 +1124,7 @@ import {...@@ -1123,6 +1124,7 @@ import {
1123 getProviderSlashCommandsForSlashMenu,1124 getProviderSlashCommandsForSlashMenu,
1124 getProviderSkillsForSlashMenu,1125 getProviderSkillsForSlashMenu,
1125 hasCompleteProviderWorkspaceSnapshot,1126 hasCompleteProviderWorkspaceSnapshot,
1127 hasCurrentProviderWorkspaceSnapshot,
1126 resolveProviderSkillsForCwd,1128 resolveProviderSkillsForCwd,
1127 resolveProviderSlashCommandsForCwd,1129 resolveProviderSlashCommandsForCwd,
1128} from "@t3tools/client-runtime/providerSkills";1130} from "@t3tools/client-runtime/providerSkills";
...@@ -2194,7 +2196,9 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)...@@ -2194,7 +2196,9 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)
2194 const refreshProviders = useAtomCommand(serverEnvironment.refreshProviders, {2196 const refreshProviders = useAtomCommand(serverEnvironment.refreshProviders, {
2195 reportFailure: false,2197 reportFailure: false,
2196 });2198 });
2197 const workspaceRefreshKeyRef = useRef<string | null>(null);2199 // The last scan this composer asked for. A request inside the TTL is not
2200 // repeated, so a client clock ahead of the server's cannot loop rescans.
2201 const workspaceRefreshKeyRef = useRef<{ key: string; requestedAt: number } | null>(null);
2198 const [workspaceRefreshRetry, setWorkspaceRefreshRetry] = useState<{2202 const [workspaceRefreshRetry, setWorkspaceRefreshRetry] = useState<{
2199 key: string;2203 key: string;
2200 notBefore: number;2204 notBefore: number;
...@@ -2237,21 +2241,23 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)...@@ -2237,21 +2241,23 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)
2237 useEffect(() => {2241 useEffect(() => {
2238 if (!gitCwd || !selectedProviderEntry) return;2242 if (!gitCwd || !selectedProviderEntry) return;
2239 const key = `${environmentId}:${selectedProviderEntry.instanceId}:${gitCwd}`;2243 const key = `${environmentId}:${selectedProviderEntry.instanceId}:${gitCwd}`;
2240 const hasWorkspaceSnapshot = hasCompleteProviderWorkspaceSnapshot(2244 const now = Date.now();
2241 selectedProviderStatus,2245 const lastRequest = workspaceRefreshKeyRef.current;
2242 gitCwd,2246 if (
2243 );2247 lastRequest?.key === key &&
2244 if (workspaceRefreshKeyRef.current === key) return;2248 now - lastRequest.requestedAt < PROVIDER_WORKSPACE_SNAPSHOT_TTL_MS
2245 if (hasWorkspaceSnapshot) {2249 )
2246 workspaceRefreshKeyRef.current = key;2250 return;
2251 if (hasCurrentProviderWorkspaceSnapshot(selectedProviderStatus, gitCwd, now)) {
2247 setWorkspaceRefreshRetry(null);2252 setWorkspaceRefreshRetry(null);
2248 return;2253 return;
2249 }2254 }
2250 const retry = workspaceRefreshRetry;2255 const retry = workspaceRefreshRetry;
2251 if (retry?.key === key && Date.now() < retry.notBefore) return;2256 if (retry?.key === key && now < retry.notBefore) return;
2252 workspaceRefreshKeyRef.current = key;2257 const request = { key, requestedAt: now };
2258 workspaceRefreshKeyRef.current = request;
2253 const retryLater = () => {2259 const retryLater = () => {
2254 if (workspaceRefreshKeyRef.current !== key) return;2260 if (workspaceRefreshKeyRef.current !== request) return;
2255 workspaceRefreshKeyRef.current = null;2261 workspaceRefreshKeyRef.current = null;
2256 setWorkspaceRefreshRetry({2262 setWorkspaceRefreshRetry({
2257 key,2263 key,
...@@ -2270,9 +2276,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)...@@ -2270,9 +2276,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)
2270 ),2276 ),
2271 gitCwd,2277 gitCwd,
2272 );2278 );
2273 if (!hasWorkspaceSnapshot && workspaceRefreshKeyRef.current === key) {2279 if (!hasWorkspaceSnapshot) retryLater();
2274 retryLater();
2275 }
2276 }, retryLater);2280 }, retryLater);
2277 }, [2281 }, [
2278 environmentId,2282 environmentId,
packages/client-runtime/src/providerSkills.test.ts+30-1
...@@ -1,4 +1,9 @@...@@ -1,4 +1,9 @@
1import { ProviderDriverKind, ProviderInstanceId, type ServerProvider } from "@t3tools/contracts";1import {
2 PROVIDER_WORKSPACE_SNAPSHOT_TTL_MS,
3 ProviderDriverKind,
4 ProviderInstanceId,
5 type ServerProvider,
6} from "@t3tools/contracts";
2import { describe, expect, it } from "vite-plus/test";7import { describe, expect, it } from "vite-plus/test";
38
4import {9import {
...@@ -7,6 +12,7 @@ import {...@@ -7,6 +12,7 @@ import {
7 getProviderSlashCommandsForSlashMenu,12 getProviderSlashCommandsForSlashMenu,
8 getProviderSkillsForSlashMenu,13 getProviderSkillsForSlashMenu,
9 hasCompleteProviderWorkspaceSnapshot,14 hasCompleteProviderWorkspaceSnapshot,
15 hasCurrentProviderWorkspaceSnapshot,
10 resolveProviderSkillsForCwd,16 resolveProviderSkillsForCwd,
11 resolveProviderSlashCommandsForCwd,17 resolveProviderSlashCommandsForCwd,
12 resolveProviderSkillSourceKind,18 resolveProviderSkillSourceKind,
...@@ -274,4 +280,27 @@ describe("workspace provider snapshots", () => {...@@ -274,4 +280,27 @@ describe("workspace provider snapshots", () => {
274 expect(hasCompleteProviderWorkspaceSnapshot(undefined, "/workspace/project-a")).toBe(false);280 expect(hasCompleteProviderWorkspaceSnapshot(undefined, "/workspace/project-a")).toBe(false);
275 expect(hasCompleteProviderWorkspaceSnapshot(provider, null)).toBe(false);281 expect(hasCompleteProviderWorkspaceSnapshot(provider, null)).toBe(false);
276 });282 });
283
284 it("asks for a rescan once the workspace snapshot outlives its TTL", () => {
285 const scannedAt = Date.parse("2026-01-01T00:01:00.000Z");
286 const cwd = "/workspace/project-a";
287 expect(hasCurrentProviderWorkspaceSnapshot(provider, cwd, scannedAt)).toBe(true);
288 expect(
289 hasCurrentProviderWorkspaceSnapshot(
290 provider,
291 cwd,
292 scannedAt + PROVIDER_WORKSPACE_SNAPSHOT_TTL_MS - 1,
293 ),
294 ).toBe(true);
295 expect(
296 hasCurrentProviderWorkspaceSnapshot(
297 provider,
298 cwd,
299 scannedAt + PROVIDER_WORKSPACE_SNAPSHOT_TTL_MS,
300 ),
301 ).toBe(false);
302 expect(hasCurrentProviderWorkspaceSnapshot(provider, "/workspace/project-b", scannedAt)).toBe(
303 false,
304 );
305 });
277});306});
packages/client-runtime/src/providerSkills.ts+19-4
...@@ -1,7 +1,8 @@...@@ -1,7 +1,8 @@
1import type {1import {
2 ServerProvider,2 isProviderWorkspaceSnapshotCurrent,
3 ServerProviderSkill,3 type ServerProvider,
4 ServerProviderSlashCommand,4 type ServerProviderSkill,
5 type ServerProviderSlashCommand,
5} from "@t3tools/contracts";6} from "@t3tools/contracts";
67
7export type ProviderSkillSourceKind = "app" | "repo" | "project" | "personal" | "system" | "other";8export type ProviderSkillSourceKind = "app" | "repo" | "project" | "personal" | "system" | "other";
...@@ -119,6 +120,20 @@ export function hasCompleteProviderWorkspaceSnapshot(...@@ -119,6 +120,20 @@ export function hasCompleteProviderWorkspaceSnapshot(
119 return Boolean(snapshot && !snapshot.slashCommandsPending);120 return Boolean(snapshot && !snapshot.slashCommandsPending);
120}121}
121122
123/** A complete snapshot young enough that opening a composer need not rescan. */
124export function hasCurrentProviderWorkspaceSnapshot(
125 provider: ServerProvider | null | undefined,
126 cwd: string | null | undefined,
127 nowMs: number,
128): boolean {
129 const snapshot = provider && resolveProviderWorkspaceSnapshot(provider, cwd);
130 return Boolean(
131 snapshot &&
132 !snapshot.slashCommandsPending &&
133 isProviderWorkspaceSnapshotCurrent(snapshot, nowMs),
134 );
135}
136
122export function resolveProviderSkillsForCwd(137export function resolveProviderSkillsForCwd(
123 provider: ServerProvider,138 provider: ServerProvider,
124 cwd: string | null | undefined,139 cwd: string | null | undefined,
packages/contracts/src/server.ts+14
...@@ -131,6 +131,20 @@ export const ServerProviderWorkspaceSnapshot = Schema.Struct({...@@ -131,6 +131,20 @@ export const ServerProviderWorkspaceSnapshot = Schema.Struct({
131});131});
132export type ServerProviderWorkspaceSnapshot = typeof ServerProviderWorkspaceSnapshot.Type;132export type ServerProviderWorkspaceSnapshot = typeof ServerProviderWorkspaceSnapshot.Type;
133133
134/**
135 * How long a workspace's skill and command scan stays current. Nothing watches
136 * skill directories, so a composer opened after this rescans on use, and the
137 * server answers repeat requests inside the window from its cache.
138 */
139export const PROVIDER_WORKSPACE_SNAPSHOT_TTL_MS = 5 * 60_000;
140
141export function isProviderWorkspaceSnapshotCurrent(
142 snapshot: Pick<ServerProviderWorkspaceSnapshot, "checkedAt">,
143 nowMs: number,
144): boolean {
145 return nowMs - Date.parse(snapshot.checkedAt) < PROVIDER_WORKSPACE_SNAPSHOT_TTL_MS;
146}
147
134/**148/**
135 * Availability of a configured provider instance from the runtime's POV.149 * Availability of a configured provider instance from the runtime's POV.
136 *150 *