From fcda42dfd2cb32115830efad8ac55b4155f27dbe Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Wed, 21 Oct 2026 21:33:27 +0000 Subject: [PATCH 1/6] test(server): reproduce evicted folder skills hidden during slow command probe Co-Authored-By: Claude Opus 5.5 --- .../src/provider/ProviderRegistry.test.ts | 127 ++++++++++++++++++ 1 file changed, 127 insertions(+) diff --git a/apps/server/src/provider/ProviderRegistry.test.ts b/apps/server/src/provider/ProviderRegistry.test.ts index a278470c286d0..ddb3d085f6cd0 100644 --- a/apps/server/src/provider/ProviderRegistry.test.ts +++ b/apps/server/src/provider/ProviderRegistry.test.ts @@ -1372,6 +1372,133 @@ it.layer( }), ); + it.effect("publishes an evicted folder's skills before its slow command probe ends", () => + Effect.gen(function* () { + const driver = ProviderDriverKind.make("claudeAgent"); + const instanceId = ProviderInstanceId.make("claudeAgent"); + const machineSkills = [{ name: "personal", path: "/home/me/SKILL.md", enabled: true }]; + const projectSkills = [{ name: "project", path: "/a/.claude/SKILL.md", enabled: true }]; + const provider = { + instanceId, + driver, + status: "ready", + enabled: true, + installed: true, + auth: { status: "authenticated" }, + checkedAt: "2026-06-10T00:00:00.000Z", + version: "1.0.0", + models: [], + slashCommands: [COMPACT_SLASH_COMMAND], + skills: machineSkills, + } as const satisfies ServerProvider; + const probeCommand = { name: "review", description: "Review changes" }; + const probeStarted = yield* Deferred.make(); + const releaseProbe = yield* Deferred.make(); + const slowProbeCwd = yield* Ref.make(null); + // Claude reads skills from disk quickly, then waits on a CLI probe for + // its commands. A driver may hand the registry what it already knows. + const snapshotForCwd = ( + cwd: string, + publishPartial?: (snapshot: ProviderWorkspaceSnapshot) => Effect.Effect, + ) => + Effect.gen(function* () { + const skills = cwd === "/a" ? projectSkills : []; + if ((yield* Ref.get(slowProbeCwd)) === cwd) { + if (publishPartial) { + yield* publishPartial({ ...provider, skills, slashCommandsPending: true }); + } + yield* Deferred.succeed(probeStarted, undefined); + yield* Deferred.await(releaseProbe); + } + return { + ...provider, + skills, + slashCommands: [COMPACT_SLASH_COMMAND, probeCommand], + slashCommandsPending: false, + } satisfies ProviderWorkspaceSnapshot; + }); + const instance: ProviderInstance = { + instanceId, + driverKind: driver, + continuationIdentity: { driverKind: driver, continuationKey: "claude:instance:claude" }, + displayName: undefined, + enabled: true, + snapshot: { + resolveMaintenance: () => + Effect.succeed( + makeManualOnlyProviderMaintenanceCapabilities({ + provider: driver, + packageName: null, + }), + ), + getSnapshot: Effect.succeed(provider), + refresh: Effect.succeed(provider), + streamChanges: Stream.empty, + applyUsageLimits: () => Effect.void, + }, + snapshotForCwd, + orchestrationAdapter: {} as ProviderInstance["orchestrationAdapter"], + textGeneration: {} as ProviderInstance["textGeneration"], + }; + const layerInstanceRegistry = Layer.succeed( + ProviderInstanceRegistry.ProviderInstanceRegistry, + { + getInstance: (requestedId) => + Effect.succeed(requestedId === instanceId ? instance : undefined), + listInstances: Effect.succeed([instance]), + listUnavailable: Effect.succeed([]), + streamChanges: Stream.empty, + subscribeChanges: Effect.flatMap(PubSub.unbounded(), PubSub.subscribe), + }, + ); + const scope = yield* Scope.make(); + yield* Effect.addFinalizer(() => Scope.close(scope, Exit.void)); + const runtimeServices = yield* Layer.build( + ProviderRegistry.layer.pipe( + Layer.provideMerge(layerInstanceRegistry), + Layer.provideMerge( + ServerConfig.layerTest(process.cwd(), { + prefix: "t3-provider-registry-evicted-workspace-", + }), + ), + Layer.provideMerge(NodeServices.layer), + ), + ).pipe(Scope.provide(scope)); + + yield* Effect.gen(function* () { + const registry = yield* ProviderRegistry.ProviderRegistry; + const workspaceA = Effect.map(registry.getProviders, (providers) => + providers[0]?.workspaceSnapshots?.find((snapshot) => snapshot.cwd === "/a"), + ); + yield* registry.refreshWorkspaceSnapshot({ instanceId, cwd: "/a" }); + assert.deepStrictEqual((yield* workspaceA)?.skills, projectSkills); + + // Sixteen other worktrees push /a out of the capped snapshot list, + // so clients fall back to the machine's personal skills for /a. + for (let index = 0; index < 16; index++) { + yield* registry.refreshWorkspaceSnapshot({ instanceId, cwd: `/other-${index}` }); + } + assert.strictEqual(yield* workspaceA, undefined); + + yield* Ref.set(slowProbeCwd, "/a"); + const rescan = yield* registry + .refreshWorkspaceSnapshot({ instanceId, cwd: "/a" }) + .pipe(Effect.forkChild); + yield* Deferred.await(probeStarted); + const whileProbing = yield* workspaceA; + assert.deepStrictEqual(whileProbing?.skills, projectSkills); + assert.strictEqual(whileProbing?.slashCommandsPending, true); + + yield* Deferred.succeed(releaseProbe, undefined); + yield* Fiber.join(rescan); + const settled = yield* workspaceA; + assert.deepStrictEqual(settled?.skills, projectSkills); + assert.deepStrictEqual(settled?.slashCommands, [COMPACT_SLASH_COMMAND, probeCommand]); + assert.strictEqual(settled?.slashCommandsPending, undefined); + }).pipe(Effect.provide(runtimeServices)); + }), + ); + it.effect("shares one in-flight pass between concurrent untargeted refreshes", () => Effect.gen(function* () { const driver = ProviderDriverKind.make("codex"); From ce22309049ea6fc7e97736fabe25fbc74343702e Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Wed, 21 Oct 2026 21:36:30 +0000 Subject: [PATCH 2/6] fix(server): Claude skills show while its command probe runs A workspace pushed out of the 16-entry snapshot list fell back to the machine's personal skills until Claude's slow capability probe finished. Drivers can now hand the registry a partial snapshot; Claude publishes its on-disk skills before starting the probe, under the same refresh ownership checks as the final result. Co-Authored-By: Claude Opus 5.5 --- apps/server/src/provider/ClaudeProvider.ts | 5 ++ .../src/provider/Drivers/ClaudeDriver.ts | 10 ++- apps/server/src/provider/ProviderRegistry.ts | 66 +++++++++++-------- packages/provider-core/src/server/driver.ts | 2 + 4 files changed, 54 insertions(+), 29 deletions(-) diff --git a/apps/server/src/provider/ClaudeProvider.ts b/apps/server/src/provider/ClaudeProvider.ts index 18f3b8508a04a..0268795863f32 100644 --- a/apps/server/src/provider/ClaudeProvider.ts +++ b/apps/server/src/provider/ClaudeProvider.ts @@ -455,9 +455,14 @@ export const probeClaudeWorkspaceSnapshot = Effect.fn("probeClaudeWorkspaceSnaps machineSnapshot: ServerProvider, cwd: string, environment?: NodeJS.ProcessEnv, + publishPartial?: (snapshot: ProviderWorkspaceSnapshot) => Effect.Effect, ): Effect.fn.Return { if (!claudeSettings.enabled) return machineSnapshot; const skills = yield* discoverClaudeSkills(claudeSettings, cwd, environment); + // The capability probe starts the CLI and can take seconds. + if (publishPartial) { + yield* publishPartial({ ...machineSnapshot, skills, slashCommandsPending: true }); + } const probeResult = yield* probeClaudeCapabilities(claudeSettings, environment, cwd, false).pipe( Effect.result, ); diff --git a/apps/server/src/provider/Drivers/ClaudeDriver.ts b/apps/server/src/provider/Drivers/ClaudeDriver.ts index 86832d53d3119..03eb4532f8f0c 100644 --- a/apps/server/src/provider/Drivers/ClaudeDriver.ts +++ b/apps/server/src/provider/Drivers/ClaudeDriver.ts @@ -396,10 +396,16 @@ export const ClaudeDriver: ProviderDriver + snapshotForCwd: (cwd, publishPartial) => snapshot.getSnapshot.pipe( Effect.flatMap((machineSnapshot) => - probeClaudeWorkspaceSnapshot(effectiveConfig, machineSnapshot, cwd, processEnv), + probeClaudeWorkspaceSnapshot( + effectiveConfig, + machineSnapshot, + cwd, + processEnv, + publishPartial, + ), ), Effect.provideService(FileSystem.FileSystem, fileSystem), Effect.provideService(Path.Path, path), diff --git a/apps/server/src/provider/ProviderRegistry.ts b/apps/server/src/provider/ProviderRegistry.ts index 97da44aa93ed6..aa3d76091d260 100644 --- a/apps/server/src/provider/ProviderRegistry.ts +++ b/apps/server/src/provider/ProviderRegistry.ts @@ -962,37 +962,49 @@ export const layer = Layer.effect( Effect.andThen(refreshInstance(input.instanceId)), ) : Effect.void; - return yield* refreshMachineSnapshot.pipe( - Effect.andThen(instance.snapshotForCwd(input.cwd)), - // A failed scan fails this effect, so a returned snapshot is a real - // inventory even while the provider's health check reports an error. - Effect.flatMap((scopedSnapshot) => - instanceRegistry.getInstance(input.instanceId).pipe( - Effect.flatMap((currentInstance) => { - if (currentInstance !== instance) return Ref.get(providersRef); - return updateProviders((currentProviders) => { - // Read ownership inside the update so a newer claim cannot race publication. - if (Ref.getUnsafe(workspaceRefreshesRef).get(instance)?.get(input.cwd) !== token) { - return currentProviders; + // Partial and final results replace whatever this scan last published, + // so a partial publication does not make the final one look stale. + let publishedFrom = scannedFrom; + const publish = (scopedSnapshot: ProviderWorkspaceSnapshot) => + instanceRegistry.getInstance(input.instanceId).pipe( + Effect.flatMap((currentInstance) => { + if (currentInstance !== instance) return Ref.get(providersRef); + return updateProviders((currentProviders) => { + // Read ownership inside the update so a newer claim cannot race publication. + if (Ref.getUnsafe(workspaceRefreshesRef).get(instance)?.get(input.cwd) !== token) { + return currentProviders; + } + return currentProviders.map((candidate) => { + if ( + candidate.instanceId !== input.instanceId || + !Equal.equals(workspaceSnapshotOf(candidate), publishedFrom) + ) { + return candidate; } - return currentProviders.map((candidate) => - candidate.instanceId === input.instanceId && - Equal.equals(workspaceSnapshotOf(candidate), scannedFrom) - ? upsertProviderWorkspaceSnapshot(candidate, input.cwd, { - ...scopedSnapshot, - checkedAt: scannedAt, - // Unless the driver reports its own command discovery, - // commands come from the health check. A failed check - // leaves them unknown, so keep the last ones and retry. - slashCommandsPending: - scopedSnapshot.slashCommandsPending ?? scopedSnapshot.status === "error", - }) - : candidate, - ); + const next = upsertProviderWorkspaceSnapshot(candidate, input.cwd, { + ...scopedSnapshot, + checkedAt: scannedAt, + // Unless the driver reports its own command discovery, + // commands come from the health check. A failed check + // leaves them unknown, so keep the last ones and retry. + slashCommandsPending: + scopedSnapshot.slashCommandsPending ?? scopedSnapshot.status === "error", + }); + publishedFrom = workspaceSnapshotOf(next); + return next; }); - }), + }); + }), + ); + return yield* refreshMachineSnapshot.pipe( + Effect.andThen( + instance.snapshotForCwd(input.cwd, (partial) => + publish({ ...partial, slashCommandsPending: true }).pipe(Effect.asVoid), ), ), + // A failed scan fails this effect, so a returned snapshot is a real + // inventory even while the provider's health check reports an error. + Effect.flatMap(publish), Effect.ensuring( Ref.update(workspaceRefreshesRef, (refreshes) => { const current = refreshes.get(instance); diff --git a/packages/provider-core/src/server/driver.ts b/packages/provider-core/src/server/driver.ts index 88355ccaf4171..69acdb190471e 100644 --- a/packages/provider-core/src/server/driver.ts +++ b/packages/provider-core/src/server/driver.ts @@ -49,6 +49,8 @@ export interface ProviderInstance { readonly snapshot: ManagedServerProvider; readonly snapshotForCwd?: ( cwd: string, + /** Publishes what a slow scan already knows, such as skills read before a CLI probe. */ + publishPartial?: (snapshot: ProviderWorkspaceSnapshot) => Effect.Effect, ) => Effect.Effect; readonly refreshModels?: () => Effect.Effect; readonly invalidateCaches?: Effect.Effect; From e5541e9fc6f9739258a63e9e91b26f3b1bf9b34d Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Wed, 11 Nov 2026 09:39:14 +0000 Subject: [PATCH 3/6] fix(server): publish a scan's final skills after its partial entry is evicted A partial publication moved the scan's freshness baseline to the partial entry, so evicting it under the 16-workspace cap made the final result look stale. Accept the scan's start state as well, as main does. AI-assisted; verified with the tests and tools listed above. --- apps/server/src/provider/ProviderRegistry.test.ts | 6 ++++++ apps/server/src/provider/ProviderRegistry.ts | 11 +++++------ 2 files changed, 11 insertions(+), 6 deletions(-) diff --git a/apps/server/src/provider/ProviderRegistry.test.ts b/apps/server/src/provider/ProviderRegistry.test.ts index 8e14a5dbcf8e1..b181bc21d9039 100644 --- a/apps/server/src/provider/ProviderRegistry.test.ts +++ b/apps/server/src/provider/ProviderRegistry.test.ts @@ -2069,6 +2069,12 @@ it.layer( assert.deepStrictEqual(whileProbing?.skills, projectSkills); assert.strictEqual(whileProbing?.slashCommandsPending, true); + // The partial entry can be evicted again before the probe finishes. + for (let index = 0; index < 16; index++) { + yield* registry.refreshWorkspaceSnapshot({ instanceId, cwd: `/later-${index}` }); + } + assert.strictEqual(yield* workspaceA, undefined); + yield* Deferred.succeed(releaseProbe, undefined); yield* Fiber.join(rescan); const settled = yield* workspaceA; diff --git a/apps/server/src/provider/ProviderRegistry.ts b/apps/server/src/provider/ProviderRegistry.ts index 3f0a09dde8666..bfc94502f2689 100644 --- a/apps/server/src/provider/ProviderRegistry.ts +++ b/apps/server/src/provider/ProviderRegistry.ts @@ -991,8 +991,8 @@ export const layer = Layer.effect( Effect.andThen(refreshInstance(input.instanceId)), ) : Effect.void; - // Partial and final results replace whatever this scan last published, - // so a partial publication does not make the final one look stale. + // Results replace what the scan started from or last published, so neither a partial + // publication nor its capacity eviction makes the final one look stale. let publishedFrom = scannedFrom; const publish = (scopedSnapshot: ProviderWorkspaceSnapshot) => instanceRegistry.getInstance(input.instanceId).pipe( @@ -1004,10 +1004,9 @@ export const layer = Layer.effect( return currentProviders; } return currentProviders.map((candidate) => { - if ( - candidate.instanceId !== input.instanceId || - !Equal.equals(workspaceSnapshotOf(candidate), publishedFrom) - ) { + if (candidate.instanceId !== input.instanceId) return candidate; + const current = workspaceSnapshotOf(candidate); + if (!Equal.equals(current, publishedFrom) && !Equal.equals(current, scannedFrom)) { return candidate; } const next = upsertProviderWorkspaceSnapshot(candidate, input.cwd, { From 85eea75e099e2292ffc65fbf3bf78d56512e923a Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Wed, 11 Nov 2026 09:58:50 +0000 Subject: [PATCH 4/6] fix(server): send partial Claude skills to on-demand clients Clients that fetch workspace snapshots on demand see a folder's snapshot only in the refresh reply, which waited for the command probe. The scan's owner now replies once partial skills are published, and the client asks again at once to join the running scan for the commands. AI-assisted; verified with the tests and tools listed above. --- .../src/provider/ProviderRegistry.test.ts | 23 ++++++--- apps/server/src/provider/ProviderRegistry.ts | 14 +++++- apps/server/src/ws.ts | 2 + .../client-runtime/src/providerSkills.test.ts | 19 ++++++++ packages/client-runtime/src/providerSkills.ts | 48 +++++++++++-------- 5 files changed, 76 insertions(+), 30 deletions(-) diff --git a/apps/server/src/provider/ProviderRegistry.test.ts b/apps/server/src/provider/ProviderRegistry.test.ts index b181bc21d9039..8ce52d4f60d27 100644 --- a/apps/server/src/provider/ProviderRegistry.test.ts +++ b/apps/server/src/provider/ProviderRegistry.test.ts @@ -2061,13 +2061,20 @@ it.layer( assert.strictEqual(yield* workspaceA, undefined); yield* Ref.set(slowProbeCwd, "/a"); + // An on-demand client sees /a only in its reply, which comes back with the partial. + const reply = yield* registry.refreshWorkspaceSnapshot({ + instanceId, + cwd: "/a", + untilPartial: true, + }); + const replied = reply[0]?.workspaceSnapshots?.find((snapshot) => snapshot.cwd === "/a"); + assert.deepStrictEqual(replied?.skills, projectSkills); + assert.strictEqual(replied?.slashCommandsPending, true); + yield* Deferred.await(probeStarted); + // The client's follow-up request joins the running scan for the rest. const rescan = yield* registry .refreshWorkspaceSnapshot({ instanceId, cwd: "/a" }) - .pipe(Effect.forkChild); - yield* Deferred.await(probeStarted); - const whileProbing = yield* workspaceA; - assert.deepStrictEqual(whileProbing?.skills, projectSkills); - assert.strictEqual(whileProbing?.slashCommandsPending, true); + .pipe(Effect.forkChild({ startImmediately: true })); // The partial entry can be evicted again before the probe finishes. for (let index = 0; index < 16; index++) { @@ -2076,8 +2083,10 @@ it.layer( assert.strictEqual(yield* workspaceA, undefined); yield* Deferred.succeed(releaseProbe, undefined); - yield* Fiber.join(rescan); - const settled = yield* workspaceA; + const settled = (yield* Fiber.join(rescan))[0]?.workspaceSnapshots?.find( + (snapshot) => snapshot.cwd === "/a", + ); + assert.deepStrictEqual(settled, yield* workspaceA); assert.deepStrictEqual(settled?.skills, projectSkills); assert.deepStrictEqual(settled?.slashCommands, [COMPACT_SLASH_COMMAND, probeCommand]); assert.strictEqual(settled?.slashCommandsPending, undefined); diff --git a/apps/server/src/provider/ProviderRegistry.ts b/apps/server/src/provider/ProviderRegistry.ts index bfc94502f2689..d228af1d6b643 100644 --- a/apps/server/src/provider/ProviderRegistry.ts +++ b/apps/server/src/provider/ProviderRegistry.ts @@ -81,6 +81,8 @@ export class ProviderRegistry extends Context.Service< /** Directory the driver scans; the snapshot stays keyed by `cwd`. Defaults to `cwd`. */ readonly scanCwd?: string; readonly fresh?: boolean; + /** Return once the scan publishes partial results; the scan keeps running to completion. */ + readonly untilPartial?: boolean; }) => Effect.Effect>; /** @@ -955,6 +957,7 @@ export const layer = Layer.effect( readonly cwd: string; readonly scanCwd?: string; readonly fresh?: boolean; + readonly untilPartial?: boolean; }) { if (input.fresh) { yield* updateProviders((providers) => @@ -986,6 +989,7 @@ export const layer = Layer.effect( const instance = yield* instanceRegistry.getInstance(input.instanceId); if (!instance?.snapshotForCwd) return providers; const token = yield* Deferred.make(); + const partialPublished = yield* Deferred.make>(); const refreshMachineSnapshot = input.fresh ? (instance.invalidateCaches ?? Effect.void).pipe( Effect.andThen(refreshInstance(input.instanceId)), @@ -1025,7 +1029,10 @@ export const layer = Layer.effect( const runScan = refreshMachineSnapshot.pipe( Effect.andThen( instance.snapshotForCwd(input.scanCwd ?? input.cwd, (partial) => - publish({ ...partial, slashCommandsPending: true }).pipe(Effect.asVoid), + publish({ ...partial, slashCommandsPending: true }).pipe( + Effect.flatMap((providers) => Deferred.succeed(partialPublished, providers)), + Effect.asVoid, + ), ), ), // Scans fail on error; returned inventory remains valid even when provider health reports an error. @@ -1066,7 +1073,10 @@ export const layer = Layer.effect( if ("inFlight" in claim) { return yield* Deferred.await(claim.inFlight).pipe(Effect.andThen(Ref.get(providersRef))); } - return yield* Fiber.join(claim.scan); + // Joining only stops waiting; the detached scan still publishes its final result. + return yield* input.untilPartial + ? Effect.raceFirst(Fiber.join(claim.scan), Deferred.await(partialPublished)) + : Fiber.join(claim.scan); }); const invalidateModelCaches = Effect.fn("ProviderRegistry.invalidateModelCaches")(function* ( diff --git a/apps/server/src/ws.ts b/apps/server/src/ws.ts index 5564b801d6b8a..0f34aed187ee4 100644 --- a/apps/server/src/ws.ts +++ b/apps/server/src/ws.ts @@ -2518,6 +2518,8 @@ export const layerWsRpc = ( cwd: input.cwd, scanCwd: yield* projectService.workspaceScanDirectory(input.cwd), fresh: input.fresh === true, + // On-demand clients see a cwd snapshot only in this reply, so send partial skills early. + untilPartial: input.workspaceSnapshotsOnDemand === true, }) : input.reloadSecrets === true ? providerRegistry.reloadSecretsAndRefresh(input.instanceId) diff --git a/packages/client-runtime/src/providerSkills.test.ts b/packages/client-runtime/src/providerSkills.test.ts index bd5729656c841..405e3034fc629 100644 --- a/packages/client-runtime/src/providerSkills.test.ts +++ b/packages/client-runtime/src/providerSkills.test.ts @@ -200,6 +200,25 @@ describe("provider workspace snapshot refresh atom", () => { unmount(); }); + it("asks again at once when the reply carries partial skills, then settles", async () => { + vi.useFakeTimers({ now: Date.parse("2026-01-01T00:00:00.000Z") }); + const partial = withSnapshot("2026-01-01T00:00:00.000Z", true); + const complete = withSnapshot("2026-01-01T00:00:00.000Z"); + const { registry, atom, providersAtom, run, unmount } = setup(noSnapshot, [ + () => partial, + () => complete, + () => partial, + ]); + await vi.advanceTimersByTimeAsync(0); + expect(run).toHaveBeenCalledTimes(2); + expect(isProviderWorkspaceSnapshotScanning(registry.get(atom))).toBe(true); + registry.set(providersAtom, complete); + await vi.advanceTimersByTimeAsync(60_000); + expect(run).toHaveBeenCalledTimes(2); + expect(isProviderWorkspaceSnapshotScanning(registry.get(atom))).toBe(false); + unmount(); + }); + it.each(["already expired", "expires during discovery"])( "retries an unchanged catalog that %s after the short cooldown", async (expiry) => { diff --git a/packages/client-runtime/src/providerSkills.ts b/packages/client-runtime/src/providerSkills.ts index 2598c895c5053..b235b865cd32a 100644 --- a/packages/client-runtime/src/providerSkills.ts +++ b/packages/client-runtime/src/providerSkills.ts @@ -311,15 +311,18 @@ export function createProviderWorkspaceSnapshotRefreshAtomFamily(input: { } 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 = () => { + // Outlives this build on purpose: a rebuild must not drop the retry. Once nothing + // subscribes, writing would recreate the node and scan with no subscriber. A pending + // reply's partial skills come before the command probe ends; the first per cycle asks + // again at once to join that scan, later ones cool down. + const retryLater = (pending: boolean) => { 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()), + pending && state.requestedAt === now && state.failedAttempts === 0 + ? { ...state, requestedAt: null, failedAttempts: 1 } + : // @effect-diagnostics-next-line globalDate:off + failProviderWorkspaceSnapshotRefresh(state, now, Date.now()), ); }; void runAtomCommand( @@ -327,21 +330,24 @@ export function createProviderWorkspaceSnapshotRefreshAtomFamily(input: { input.refreshProviders, { environmentId, input: { instanceId, cwd } }, { reportFailure: false }, - ).then((result) => { - const refreshedProvider = - result._tag === "Success" ? findProvider(result.value.providers) : undefined; - const snapshot = - refreshedProvider && resolveProviderWorkspaceSnapshot(refreshedProvider, cwd); - // A new server timestamp proves progress even when the client clock is ahead. - // @effect-diagnostics-next-line globalDate:off - const completedAt = Date.now(); - const refreshed = - hasCompleteProviderWorkspaceSnapshot(refreshedProvider, cwd) && - (hasCurrentProviderWorkspaceSnapshot(refreshedProvider, cwd, completedAt) || - snapshot?.checkedAt !== - (provider && resolveProviderWorkspaceSnapshot(provider, cwd))?.checkedAt); - if (!refreshed) retryLater(); - }, retryLater); + ).then( + (result) => { + const refreshedProvider = + result._tag === "Success" ? findProvider(result.value.providers) : undefined; + const snapshot = + refreshedProvider && resolveProviderWorkspaceSnapshot(refreshedProvider, cwd); + // A new server timestamp proves progress even when the client clock is ahead. + // @effect-diagnostics-next-line globalDate:off + const completedAt = Date.now(); + const refreshed = + hasCompleteProviderWorkspaceSnapshot(refreshedProvider, cwd) && + (hasCurrentProviderWorkspaceSnapshot(refreshedProvider, cwd, completedAt) || + snapshot?.checkedAt !== + (provider && resolveProviderWorkspaceSnapshot(provider, cwd))?.checkedAt); + if (!refreshed) retryLater(snapshot?.slashCommandsPending === true); + }, + () => retryLater(false), + ); return plan.state; }, (ctx, update: Update) => { From 3b4314bff83524cd355a23ab21528b3670a6c3de Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Fri, 13 Nov 2026 16:47:20 +0000 Subject: [PATCH 5/6] fix(client-runtime): partial Claude skill replies no longer spend the retry budget An early partial reply now makes the next request join the running scan without counting as a failed attempt. Only the joined reply counts, so a transient probe failure followed by a slow successful probe still delivers the final commands to on-demand clients. Co-Authored-By: Claude Opus 5.5 --- .../client-runtime/src/providerSkills.test.ts | 11 ++++++++-- packages/client-runtime/src/providerSkills.ts | 20 +++++++++++++------ 2 files changed, 23 insertions(+), 8 deletions(-) diff --git a/packages/client-runtime/src/providerSkills.test.ts b/packages/client-runtime/src/providerSkills.test.ts index 66c2816997140..815a0e1a964d6 100644 --- a/packages/client-runtime/src/providerSkills.test.ts +++ b/packages/client-runtime/src/providerSkills.test.ts @@ -214,21 +214,28 @@ describe("provider workspace snapshot refresh atom", () => { unmount(); }); - it("asks again at once when the reply carries partial skills, then settles", async () => { + it("joins a scan after its partial reply without spending the retry budget", async () => { vi.useFakeTimers({ now: Date.parse("2026-01-01T00:00:00.000Z") }); const partial = withSnapshot("2026-01-01T00:00:00.000Z", true); const complete = withSnapshot("2026-01-01T00:00:00.000Z"); + // The first probe fails, so the joined request also comes back pending. const { registry, atom, providersAtom, run, unmount } = setup(noSnapshot, [ + () => partial, + () => partial, () => partial, () => complete, () => partial, ]); await vi.advanceTimersByTimeAsync(0); expect(run).toHaveBeenCalledTimes(2); + await vi.advanceTimersByTimeAsync(10_000); + registry.refresh(atom); + await vi.advanceTimersByTimeAsync(0); + expect(run).toHaveBeenCalledTimes(4); expect(isProviderWorkspaceSnapshotScanning(registry.get(atom))).toBe(true); registry.set(providersAtom, complete); await vi.advanceTimersByTimeAsync(60_000); - expect(run).toHaveBeenCalledTimes(2); + expect(run).toHaveBeenCalledTimes(4); expect(isProviderWorkspaceSnapshotScanning(registry.get(atom))).toBe(false); unmount(); }); diff --git a/packages/client-runtime/src/providerSkills.ts b/packages/client-runtime/src/providerSkills.ts index 4db7da12d081c..89f2f1a194b33 100644 --- a/packages/client-runtime/src/providerSkills.ts +++ b/packages/client-runtime/src/providerSkills.ts @@ -170,6 +170,8 @@ export interface ProviderWorkspaceSnapshotRefreshState { readonly hadSnapshot: boolean; /** The server published a partial snapshot and is still discovering. */ readonly snapshotPending: boolean; + /** The last reply was an early partial, so the next request joins its scan. */ + readonly joining: boolean; } const initialProviderWorkspaceSnapshotRefreshState: ProviderWorkspaceSnapshotRefreshState = { @@ -178,6 +180,7 @@ const initialProviderWorkspaceSnapshotRefreshState: ProviderWorkspaceSnapshotRef failedAttempts: 0, hadSnapshot: false, snapshotPending: false, + joining: false, }; /** @@ -215,6 +218,7 @@ function planProviderWorkspaceSnapshotRefresh( : previous.failedAttempts, hadSnapshot: hasSnapshot, snapshotPending: pending, + joining: hasSnapshot ? false : previous.joining, }; const refresh = (requestedAt === null || now - requestedAt >= PROVIDER_WORKSPACE_SNAPSHOT_TTL_MS) && @@ -239,12 +243,15 @@ function failProviderWorkspaceSnapshotRefresh( // 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 }; + if (failedAttempts >= WORKSPACE_SNAPSHOT_MAX_ATTEMPTS) { + return { ...state, failedAttempts, joining: false }; + } return { ...state, requestedAt: null, retryNotBefore: now + WORKSPACE_SNAPSHOT_RETRY_COOLDOWN_MS, failedAttempts, + joining: false, }; } @@ -332,16 +339,17 @@ export function createProviderWorkspaceSnapshotRefreshAtomFamily(input: { } if (!plan.refresh) return plan.state; const registry = get.registry; + const joining = plan.state.joining; // Outlives this build on purpose: a rebuild must not drop the retry. Once nothing - // subscribes, writing would recreate the node and scan with no subscriber. A pending - // reply's partial skills come before the command probe ends; the first per cycle asks - // again at once to join that scan, later ones cool down. + // subscribes, writing would recreate the node and scan with no subscriber. A partial + // reply arrives before the command probe ends: ask again at once to join that scan, + // and count only the joined reply as an attempt. const retryLater = (pending: boolean) => { const node = registry.getNodes().get(refreshAtom); if (node === undefined || node.canBeRemoved) return; registry.set(refreshAtom, (state) => - pending && state.requestedAt === now && state.failedAttempts === 0 - ? { ...state, requestedAt: null, failedAttempts: 1 } + pending && !joining && state.requestedAt === now + ? { ...state, requestedAt: null, joining: true } : // @effect-diagnostics-next-line globalDate:off failProviderWorkspaceSnapshotRefresh(state, now, Date.now()), ); From f98d543b7ea990e4163922713998761d8f82e115 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Fri, 13 Nov 2026 16:56:02 +0000 Subject: [PATCH 6/6] fix(server): only clients that join a scan get early partial skill replies Every on-demand client sets workspaceSnapshotsOnDemand, including builds without the joining retry, which treat a pending reply as a failed attempt and wait out the cooldown. The early reply is now gated on a new acceptPartialWorkspaceSnapshot request flag that only the updated refresh atom sends; older clients wait for the full scan as before. Co-Authored-By: Claude Opus 5.5 --- apps/server/src/ws.ts | 3 +-- packages/client-runtime/src/providerSkills.test.ts | 2 +- packages/client-runtime/src/providerSkills.ts | 8 ++++++-- packages/contracts/src/rpc.ts | 5 +++++ 4 files changed, 13 insertions(+), 5 deletions(-) diff --git a/apps/server/src/ws.ts b/apps/server/src/ws.ts index 8a35303380b66..df37c9abd6bfc 100644 --- a/apps/server/src/ws.ts +++ b/apps/server/src/ws.ts @@ -2447,8 +2447,7 @@ export const layerWsRpc = ( cwd: input.cwd, scanCwd: yield* projectService.workspaceScanDirectory(input.cwd), fresh: input.fresh === true, - // On-demand clients see a cwd snapshot only in this reply, so send partial skills early. - untilPartial: input.workspaceSnapshotsOnDemand === true, + untilPartial: input.acceptPartialWorkspaceSnapshot === true, }) : input.reloadSecrets === true ? providerRegistry.reloadSecretsAndRefresh(input.instanceId) diff --git a/packages/client-runtime/src/providerSkills.test.ts b/packages/client-runtime/src/providerSkills.test.ts index 815a0e1a964d6..5b8c9d10c9ee7 100644 --- a/packages/client-runtime/src/providerSkills.test.ts +++ b/packages/client-runtime/src/providerSkills.test.ts @@ -205,7 +205,7 @@ describe("provider workspace snapshot refresh atom", () => { expect(run).toHaveBeenCalledTimes(1); expect(run).toHaveBeenCalledWith(expect.anything(), { environmentId, - input: { instanceId: provider.instanceId, cwd }, + input: { instanceId: provider.instanceId, cwd, acceptPartialWorkspaceSnapshot: true }, }); responses.push(() => withSnapshot("2026-01-01T00:00:00.000Z")); await vi.advanceTimersByTimeAsync(0); diff --git a/packages/client-runtime/src/providerSkills.ts b/packages/client-runtime/src/providerSkills.ts index 89f2f1a194b33..c5dfe565c6e80 100644 --- a/packages/client-runtime/src/providerSkills.ts +++ b/packages/client-runtime/src/providerSkills.ts @@ -302,7 +302,11 @@ export function createProviderWorkspaceSnapshotRefreshAtomFamily(input: { readonly refreshProviders: AtomCommand< { readonly environmentId: EnvironmentId; - readonly input: { readonly instanceId: ProviderInstanceId; readonly cwd: string }; + readonly input: { + readonly instanceId: ProviderInstanceId; + readonly cwd: string; + readonly acceptPartialWorkspaceSnapshot: boolean; + }; }, { readonly providers: ReadonlyArray }, unknown @@ -357,7 +361,7 @@ export function createProviderWorkspaceSnapshotRefreshAtomFamily(input: { void runAtomCommand( registry, input.refreshProviders, - { environmentId, input: { instanceId, cwd } }, + { environmentId, input: { instanceId, cwd, acceptPartialWorkspaceSnapshot: true } }, { reportFailure: false }, ).then( (result) => { diff --git a/packages/contracts/src/rpc.ts b/packages/contracts/src/rpc.ts index adba6dbaca71b..c2fe573ade902 100644 --- a/packages/contracts/src/rpc.ts +++ b/packages/contracts/src/rpc.ts @@ -674,6 +674,11 @@ export const WsServerRefreshProvidersRpc = Rpc.make(WS_METHODS.serverRefreshProv * refresh, at most one TTL later. */ workspaceSnapshotsOnDemand: Schema.optional(Schema.Boolean), + /** + * Reply once the cwd scan publishes partial results, such as skills before commands. Sent by + * clients that ask again at once to join the scan; older clients wait for the full scan. + */ + acceptPartialWorkspaceSnapshot: Schema.optional(Schema.Boolean), /** As on `subscribeServerConfig`: add /usage-limits to the replied snapshot. */ usageLimitsCommand: Schema.optional(Schema.Boolean), /** As on `subscribeServerConfig`: a failed cwd scan replies with the machine catalog as a pending snapshot. */