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
99import type { EnvironmentThreadShell } from "@t3tools/client-runtime/state/models";
1010
1111const 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";
1316import { Alert } from "react-native";
1417import { formatComposerContextReference } from "@t3tools/shared/composerContextReferences";
1518import { pullRequestComposerContext, threadComposerContext } from "../../lib/composerContext";
......@@ -37,6 +40,7 @@ import {
3740 getProviderSlashCommandsForSlashMenu,
3841 isProviderSkillUserInvocable,
3942 hasCompleteProviderWorkspaceSnapshot,
43 hasCurrentProviderWorkspaceSnapshot,
4044 resolveProviderSkillsForCwd,
4145 resolveProviderSlashCommandsForCwd,
4246} from "@t3tools/client-runtime/providerSkills";
......@@ -250,7 +254,9 @@ export function useComposerCommandMenu({
250254 selectedProviderStatus,
251255 projectCwd,
252256 );
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);
254260 const [workspaceRefreshRetry, setWorkspaceRefreshRetry] = useState<{
255261 key: string;
256262 notBefore: number;
......@@ -289,17 +295,23 @@ export function useComposerCommandMenu({
289295 useEffect(() => {
290296 if (!environmentId || !projectCwd || !selectedProviderInstanceId) return;
291297 const key = `${environmentId}:${selectedProviderInstanceId}:${projectCwd}`;
292 if (workspaceRefreshKeyRef.current === key) return;
293 if (hasWorkspaceSnapshot) {
294 workspaceRefreshKeyRef.current = key;
298 const now = Date.now();
299 const lastRequest = workspaceRefreshKeyRef.current;
300 if (
301 lastRequest?.key === key &&
302 now - lastRequest.requestedAt < PROVIDER_WORKSPACE_SNAPSHOT_TTL_MS
303 )
304 return;
305 if (hasCurrentProviderWorkspaceSnapshot(selectedProviderStatus, projectCwd, now)) {
295306 setWorkspaceRefreshRetry(null);
296307 return;
297308 }
298309 const retry = workspaceRefreshRetry;
299 if (retry?.key === key && Date.now() < retry.notBefore) return;
300 workspaceRefreshKeyRef.current = key;
310 if (retry?.key === key && now < retry.notBefore) return;
311 const request = { key, requestedAt: now };
312 workspaceRefreshKeyRef.current = request;
301313 const retryLater = () => {
302 if (workspaceRefreshKeyRef.current !== key) return;
314 if (workspaceRefreshKeyRef.current !== request) return;
303315 workspaceRefreshKeyRef.current = null;
304316 setWorkspaceRefreshRetry({
305317 key,
......@@ -318,17 +330,15 @@ export function useComposerCommandMenu({
318330 ),
319331 projectCwd,
320332 );
321 if (!refreshed && workspaceRefreshKeyRef.current === key) {
322 retryLater();
323 }
333 if (!refreshed) retryLater();
324334 }, retryLater);
325335 }, [
326336 draftMessage,
327337 environmentId,
328 hasWorkspaceSnapshot,
329338 projectCwd,
330339 refreshProviders,
331340 selectedProviderInstanceId,
341 selectedProviderStatus,
332342 workspaceRefreshRetry,
333343 ]);
334344
apps/server/src/provider/ProviderRegistry.test.ts+9-1
......@@ -23,6 +23,7 @@ import {
2323 ClaudeSettings,
2424 CodexSettings,
2525 DEFAULT_SERVER_SETTINGS,
26 PROVIDER_WORKSPACE_SNAPSHOT_TTL_MS,
2627 ProviderDriverKind,
2728 ProviderInstanceId,
2829 ServerSettings,
......@@ -1768,6 +1769,13 @@ it.layer(
17681769 );
17691770 yield* registry.refreshWorkspaceSnapshot({ instanceId, cwd: "/workspace" });
17701771 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);
17711779 const newSkills = [
17721780 ...scopedProvider.skills,
17731781 { name: "added", path: "/workspace/added/SKILL.md", enabled: true },
......@@ -1778,7 +1786,7 @@ it.layer(
17781786 cwd: "/workspace",
17791787 fresh: true,
17801788 });
1781 assert.strictEqual(yield* Ref.get(snapshotCalls), 4);
1789 assert.strictEqual(yield* Ref.get(snapshotCalls), 5);
17821790 assert.strictEqual(yield* Ref.get(cacheInvalidations), 1);
17831791 assert.deepStrictEqual(
17841792 (yield* registry.getProviders)[0]?.workspaceSnapshots?.map((s) => s.skills),
apps/server/src/provider/ProviderRegistry.ts+14-2
......@@ -29,6 +29,7 @@
2929 */
3030import {
3131 defaultInstanceIdForDriver,
32 isProviderWorkspaceSnapshotCurrent,
3233 ProviderDriverKind,
3334 type ProviderInstanceId,
3435 type ServerProvider,
......@@ -36,6 +37,7 @@ import {
3637} from "@t3tools/contracts";
3738import * as Cause from "effect/Cause";
3839import * as Context from "effect/Context";
40import * as DateTime from "effect/DateTime";
3941import * as Deferred from "effect/Deferred";
4042import * as Effect from "effect/Effect";
4143import * as Equal from "effect/Equal";
......@@ -1048,13 +1050,20 @@ export const layer = Layer.effect(
10481050 const workspaceSnapshotOf = (candidate: ServerProvider | undefined) =>
10491051 candidate?.workspaceSnapshots?.find((s) => s.cwd === input.cwd);
10501052 const scannedFrom = workspaceSnapshotOf(provider);
1053 const now = yield* DateTime.now;
10511054 if (
10521055 !provider ||
10531056 !provider.enabled ||
1054 (!input.fresh && scannedFrom && !scannedFrom.slashCommandsPending)
1057 (!input.fresh &&
1058 scannedFrom &&
1059 !scannedFrom.slashCommandsPending &&
1060 isProviderWorkspaceSnapshotCurrent(scannedFrom, DateTime.toEpochMillis(now)))
10551061 ) {
10561062 return providers;
10571063 }
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);
10581067 const instance = yield* instanceRegistry.getInstance(input.instanceId);
10591068 if (!instance?.snapshotForCwd) return providers;
10601069 const claimed = yield* Ref.modify(workspaceRefreshesRef, (refreshes) => {
......@@ -1086,7 +1095,10 @@ export const layer = Layer.effect(
10861095 currentProviders.map((candidate) =>
10871096 candidate.instanceId === input.instanceId &&
10881097 Equal.equals(workspaceSnapshotOf(candidate), scannedFrom)
1089 ? upsertProviderWorkspaceSnapshot(candidate, input.cwd, scopedSnapshot)
1098 ? upsertProviderWorkspaceSnapshot(candidate, input.cwd, {
1099 ...scopedSnapshot,
1100 checkedAt: scannedAt,
1101 })
10901102 : candidate,
10911103 ),
10921104 );
apps/web/src/components/chat/ChatComposer.tsx+18-14
......@@ -44,6 +44,7 @@ import {
4444 PROVIDER_SEND_TURN_MAX_ATTACHMENTS,
4545 PROVIDER_SEND_TURN_MAX_IMAGE_BYTES,
4646 PROVIDER_SEND_TURN_MAX_INPUT_CHARS,
47 PROVIDER_WORKSPACE_SNAPSHOT_TTL_MS,
4748} from "@t3tools/contracts";
4849import type { EnvironmentConnectionPresentation } from "@t3tools/client-runtime/connection";
4950import {
......@@ -1123,6 +1124,7 @@ import {
11231124 getProviderSlashCommandsForSlashMenu,
11241125 getProviderSkillsForSlashMenu,
11251126 hasCompleteProviderWorkspaceSnapshot,
1127 hasCurrentProviderWorkspaceSnapshot,
11261128 resolveProviderSkillsForCwd,
11271129 resolveProviderSlashCommandsForCwd,
11281130} from "@t3tools/client-runtime/providerSkills";
......@@ -2194,7 +2196,9 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)
21942196 const refreshProviders = useAtomCommand(serverEnvironment.refreshProviders, {
21952197 reportFailure: false,
21962198 });
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);
21982202 const [workspaceRefreshRetry, setWorkspaceRefreshRetry] = useState<{
21992203 key: string;
22002204 notBefore: number;
......@@ -2237,21 +2241,23 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)
22372241 useEffect(() => {
22382242 if (!gitCwd || !selectedProviderEntry) return;
22392243 const key = `${environmentId}:${selectedProviderEntry.instanceId}:${gitCwd}`;
2240 const hasWorkspaceSnapshot = hasCompleteProviderWorkspaceSnapshot(
2241 selectedProviderStatus,
2242 gitCwd,
2243 );
2244 if (workspaceRefreshKeyRef.current === key) return;
2245 if (hasWorkspaceSnapshot) {
2246 workspaceRefreshKeyRef.current = key;
2244 const now = Date.now();
2245 const lastRequest = workspaceRefreshKeyRef.current;
2246 if (
2247 lastRequest?.key === key &&
2248 now - lastRequest.requestedAt < PROVIDER_WORKSPACE_SNAPSHOT_TTL_MS
2249 )
2250 return;
2251 if (hasCurrentProviderWorkspaceSnapshot(selectedProviderStatus, gitCwd, now)) {
22472252 setWorkspaceRefreshRetry(null);
22482253 return;
22492254 }
22502255 const retry = workspaceRefreshRetry;
2251 if (retry?.key === key && Date.now() < retry.notBefore) return;
2252 workspaceRefreshKeyRef.current = key;
2256 if (retry?.key === key && now < retry.notBefore) return;
2257 const request = { key, requestedAt: now };
2258 workspaceRefreshKeyRef.current = request;
22532259 const retryLater = () => {
2254 if (workspaceRefreshKeyRef.current !== key) return;
2260 if (workspaceRefreshKeyRef.current !== request) return;
22552261 workspaceRefreshKeyRef.current = null;
22562262 setWorkspaceRefreshRetry({
22572263 key,
......@@ -2270,9 +2276,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)
22702276 ),
22712277 gitCwd,
22722278 );
2273 if (!hasWorkspaceSnapshot && workspaceRefreshKeyRef.current === key) {
2274 retryLater();
2275 }
2279 if (!hasWorkspaceSnapshot) retryLater();
22762280 }, retryLater);
22772281 }, [
22782282 environmentId,
packages/client-runtime/src/providerSkills.test.ts+30-1
......@@ -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";
27import { describe, expect, it } from "vite-plus/test";
38
49import {
......@@ -7,6 +12,7 @@ import {
712 getProviderSlashCommandsForSlashMenu,
813 getProviderSkillsForSlashMenu,
914 hasCompleteProviderWorkspaceSnapshot,
15 hasCurrentProviderWorkspaceSnapshot,
1016 resolveProviderSkillsForCwd,
1117 resolveProviderSlashCommandsForCwd,
1218 resolveProviderSkillSourceKind,
......@@ -274,4 +280,27 @@ describe("workspace provider snapshots", () => {
274280 expect(hasCompleteProviderWorkspaceSnapshot(undefined, "/workspace/project-a")).toBe(false);
275281 expect(hasCompleteProviderWorkspaceSnapshot(provider, null)).toBe(false);
276282 });
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 });
277306});
packages/client-runtime/src/providerSkills.ts+19-4
......@@ -1,7 +1,8 @@
1import type {
2 ServerProvider,
3 ServerProviderSkill,
4 ServerProviderSlashCommand,
1import {
2 isProviderWorkspaceSnapshotCurrent,
3 type ServerProvider,
4 type ServerProviderSkill,
5 type ServerProviderSlashCommand,
56} from "@t3tools/contracts";
67
78export type ProviderSkillSourceKind = "app" | "repo" | "project" | "personal" | "system" | "other";
......@@ -119,6 +120,20 @@ export function hasCompleteProviderWorkspaceSnapshot(
119120 return Boolean(snapshot && !snapshot.slashCommandsPending);
120121}
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
122137export function resolveProviderSkillsForCwd(
123138 provider: ServerProvider,
124139 cwd: string | null | undefined,
packages/contracts/src/server.ts+14
......@@ -131,6 +131,20 @@ export const ServerProviderWorkspaceSnapshot = Schema.Struct({
131131});
132132export 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
134148/**
135149 * Availability of a configured provider instance from the runtime's POV.
136150 *